1
Fork 0

holy cow it works

This commit is contained in:
Steffo 2024-11-30 14:07:23 +01:00
parent c3cf86f6e9
commit 15ad3dfb94
Signed by: steffo
GPG key ID: 5ADA3868646C3FC0
28 changed files with 490 additions and 158 deletions

2
.gitignore vendored
View file

@ -1 +1 @@
.env.local
.env

View file

@ -3,7 +3,9 @@
<option name="buildProfileId" value="dev" />
<option name="command" value="run" />
<option name="workingDirectory" value="file://$PROJECT_DIR$/holycow_backend" />
<envs />
<envs>
<env name="RUST_LOG" value="trace" />
</envs>
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />

View file

@ -1,4 +1,5 @@
:30002 {
reverse_proxy http://localhost:30000
reverse_proxy /api http://localhost:30001
reverse_proxy /api/* http://localhost:30001
reverse_proxy /telegram/webhook http://localhost:30001
}

View file

@ -664,6 +664,7 @@ dependencies = [
"micronfig",
"pretty_env_logger",
"serde",
"serde_json",
"skillratings",
"teloxide",
"tokio",

View file

@ -17,3 +17,4 @@ tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] }
serde = { version = "1.0.215", features = ["derive"] }
chrono = { version = "0.4.38", features = ["serde"] }
url = { version = "2.5.4", features = ["serde"] }
serde_json = "1.0.133"

View file

@ -0,0 +1 @@
ALTER TABLE players DROP COLUMN IF EXISTS username;

View file

@ -0,0 +1 @@
ALTER TABLE players ADD COLUMN username BPCHAR UNIQUE NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE matches ADD CONSTRAINT match_unique_name UNIQUE (name);

View file

@ -0,0 +1 @@
ALTER TABLE matches DROP CONSTRAINT match_unique_name;

View file

@ -3,4 +3,6 @@ micronfig::config! {
BACKEND_BIND_ADDRESS: String > std::net::SocketAddr,
TELEGRAM_API_KEY: String,
TELEGRAM_WEBHOOK_URL: String > url::Url,
TELEGRAM_NOTIFICATION_CHAT_ID: String > i64,
TELEGRAM_NOTIFICATION_TOPIC_ID?: String > i32,
}

View file

@ -5,7 +5,7 @@ use diesel::backend::Backend;
use diesel::deserialize::FromSql;
use diesel::dsl::insert_into;
use diesel::pg::Pg;
use diesel::serialize::ToSql;
use diesel::serialize::{IsNull, ToSql};
use diesel::sql_types as sql;
use diesel::serialize::Output as DieselOutput;
use diesel::ExpressionMethods;
@ -32,6 +32,7 @@ pub struct Player {
pub wenglin: WengLinRating,
pub telegram_id: Option<TelegramId>,
pub competitive: bool,
pub username: String,
}
#[derive(Debug, Clone, Insertable, Serialize, Deserialize)]
@ -39,8 +40,9 @@ pub struct Player {
#[diesel(check_for_backend(Pg))]
pub struct PlayerI {
pub wenglin: WengLinRating,
pub telegram_id: TelegramId,
pub telegram_id: Option<TelegramId>,
pub competitive: bool,
pub username: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize)]
@ -71,7 +73,6 @@ pub struct Match {
#[diesel(table_name = schema::matches)]
#[diesel(check_for_backend(Pg))]
pub struct MatchI {
pub instant: DateTime<Utc>,
pub name: Option<String>,
pub player_a_id: i32,
pub player_a_wenglin_before: WengLinRating,
@ -92,8 +93,7 @@ impl FromSql<sql::BigInt, Pg> for TelegramId {
impl FromSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let rating = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
let uncertainty = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
let (rating, uncertainty) = <(f64, f64) as FromSql<sql::Record<(sql::Double, sql::Double)>, Pg>>::from_sql(bytes)?;
let rating = skillratings::weng_lin::WengLinRating::from((rating, uncertainty));
Ok(Self(rating))
@ -121,10 +121,10 @@ impl ToSql<sql::BigInt, Pg> for TelegramId {
impl ToSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
fn to_sql<'b>(&'b self, out: &mut DieselOutput<'b, '_, Pg>) -> diesel::serialize::Result {
<f64 as ToSql<sql::Double, Pg>>::to_sql(&self.0.rating, out)?;
<f64 as ToSql<sql::Double, Pg>>::to_sql(&self.0.uncertainty, out)?;
Ok(diesel::serialize::IsNull::No)
diesel::serialize::WriteTuple::<(sql::Double, sql::Double)>::write_tuple(
&(self.0.rating, self.0.uncertainty),
out
)
}
}
@ -138,24 +138,54 @@ impl ToSql<schema::sql_types::OutcomeT, Pg> for Outcome {
}
)?;
Ok(diesel::serialize::IsNull::No)
Ok(IsNull::No)
}
}
impl From<Outcome> for skillratings::Outcomes {
fn from(value: Outcome) -> Self {
match value {
Outcome::AWins => Self::WIN,
Outcome::BWins => Self::LOSS,
Outcome::Tie => Self::DRAW,
}
}
}
impl WengLinRating {
pub fn human_score(&self) -> i64 {
let rating = self.0.rating;
let uncertainty = self.0.uncertainty;
log::debug!("Getting human score for: {rating:?}±{uncertainty:?}");
let uncertain = self.0.rating - self.0.uncertainty;
log::trace!("Minimum score is: {uncertain:?}");
let multiplied = uncertain * 100.0;
log::trace!("Multiplied score is: {multiplied:?}");
let floored: f64 = multiplied.floor();
log::trace!("Floored score is: {floored:?}");
let converted: i64 = floored as i64;
log::debug!("Human score for {rating:?}±{uncertainty:?} is {converted:?}");
converted
}
}
impl Player {
pub fn total(conn: &mut PgConnection) -> QueryResult<i64> {
log::debug!("Querying total amount of players...");
schema::players::table
.select(diesel::dsl::count_star())
.get_result::<i64>(conn)
}
pub fn all(conn: &mut PgConnection) -> QueryResult<Vec<Self>> {
log::debug!("Querying all players...");
schema::players::table
.select(Self::as_select())
.get_results::<Self>(conn)
}
pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult<Option<Self>> {
log::debug!("Querying player with id: {player_id:?}");
schema::players::table
.select(Self::as_select())
.filter(schema::players::id.eq(player_id))
@ -164,6 +194,7 @@ impl Player {
}
pub fn get_by_telegram_id(conn: &mut PgConnection, telegram_id: TelegramId) -> QueryResult<Option<Self>> {
log::debug!("Querying player with telegram id: {telegram_id:?}");
schema::players::table
.select(Self::as_select())
.filter(schema::players::telegram_id.eq(telegram_id))
@ -178,6 +209,12 @@ impl Player {
pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult<i64> {
Match::won_by_count(conn, self.id)
}
pub fn update_wenglin(self, conn: &mut PgConnection, value: &WengLinRating) -> QueryResult<Self> {
diesel::update(schema::players::table.find(self.id))
.set(schema::players::wenglin.eq(value))
.get_result(conn)
}
}
impl Match {
@ -214,9 +251,9 @@ impl Match {
}
impl PlayerI {
pub fn insert(self, conn: &mut PgConnection) -> QueryResult<Player> {
pub fn insert(&self, conn: &mut PgConnection) -> QueryResult<Player> {
insert_into(schema::players::table)
.values(&[self])
.values(self)
.get_result::<Player>(conn)
}
}
@ -224,7 +261,7 @@ impl PlayerI {
impl MatchI {
pub fn insert(self, conn: &mut PgConnection) -> QueryResult<Match> {
insert_into(schema::matches::table)
.values(&[self])
.values(self)
.get_result::<Match>(conn)
}
}

View file

@ -38,6 +38,7 @@ diesel::table! {
wenglin -> WenglinT,
telegram_id -> Nullable<Int8>,
competitive -> Bool,
username -> Bpchar,
}
}

View file

@ -1,11 +1,11 @@
use std::convert::Infallible;
use std::process::exit;
use anyhow::Context;
use axum::Extension;
use diesel::{Connection, PgConnection};
use diesel_migrations::MigrationHarness;
use teloxide::dispatching::{DefaultKey, MessageFilterExt};
use teloxide::dispatching::DefaultKey;
use teloxide::error_handlers::LoggingErrorHandler;
use teloxide::types::Message;
use teloxide::update_listeners::webhooks::Options;
mod config;
@ -72,19 +72,22 @@ async fn main() -> anyhow::Result<Infallible> {
.route("/api/matches/",
axum::routing::get(routes::matches::get_all)
)
.route("/api/matches/",
axum::routing::post(routes::matches::post_match)
)
.nest("/telegram/webhook",
telegram_router
)
.layer(Extension(bot.clone()))
;
log::trace!("Setting up Telegram dispatcher...");
let mut telegram_dispatcher = teloxide::dispatching::Dispatcher::<teloxide::Bot, anyhow::Error, DefaultKey>::builder(
bot.clone(),
Message::filter_web_app_data()
.endpoint(telegram::webapp::process_data)
teloxide::dptree::entry()
)
.default_handler(|u| async move {
log::trace!("Unhandled update: {u:#?}")
log::trace!("Unhandled update: {u:?}")
})
.build();

View file

@ -1,8 +1,13 @@
use axum::http::StatusCode;
use axum::Json;
use axum::{Extension, Json};
use diesel::{Connection, PgConnection};
use serde::Deserialize;
use skillratings::weng_lin::WengLinConfig;
use teloxide::Bot;
use teloxide::requests::Requester;
use teloxide::types::{ChatId, MessageId, ThreadId};
use crate::config;
use crate::database::model::Match;
use crate::database::model::{Match, MatchI, Outcome, Player, WengLinRating};
#[axum::debug_handler]
pub async fn get_all()
@ -16,3 +21,136 @@ pub async fn get_all()
Ok(Json(matches))
}
#[derive(Debug, Clone, Deserialize)]
pub struct MatchII {
name: Option<String>,
player_a: i32,
player_b: i32,
outcome: Outcome,
}
fn player_to_text(player: &Player, before: &WengLinRating, after: &WengLinRating) -> String {
let name = &player.username;
let competitive = &player.competitive;
match competitive {
false => {
format!("{name}")
},
true => {
let before = before.human_score();
let after = after.human_score();
let change = after - before;
format!("<b>{name}</b> <i>({change})</i>")
},
}
}
fn match_to_text(r#match: &Match, player_a: &Player, player_b: &Player) -> String {
let player_a = player_to_text(player_a, &r#match.player_a_wenglin_before, &r#match.player_a_wenglin_after);
let player_b = player_to_text(player_b, &r#match.player_b_wenglin_before, &r#match.player_b_wenglin_after);
match r#match.outcome {
Outcome::AWins => match &r#match.name {
Some(name) => format!("🔵 {player_a} ha trionfato su {player_b} in <b>{name}</b>!"),
None => format!("🔵 {player_a} ha trionfato su {player_b}!"),
},
Outcome::BWins => match &r#match.name {
Some(name) => format!("⚪️ {player_a} è stato sconfitto da {player_b} in <b>{name}</b>!"),
None => format!("⚪️ {player_a} è stato sconfitto da {player_b}!"),
},
Outcome::Tie => match &r#match.name {
Some(name) => format!("🟠 {player_a} e {player_b} hanno pareggiato in <b>{name}</b>!"),
None => format!("🟠 {player_a} e {player_b} hanno pareggiato!"),
},
}
}
pub async fn post_match(
Extension(bot): Extension<Bot>,
Json(matchii): Json<MatchII>,
)
-> Result<Json<Match>, StatusCode>
{
log::debug!("New MatchII just dropped: {matchii:#?}");
let name = matchii.name;
let outcome = matchii.outcome;
log::trace!("Establishing database connection...");
let mut conn = PgConnection::establish(config::DATABASE_URL())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log::trace!("Finding player A's info...");
let player_a = Player::get_by_id(&mut conn, matchii.player_a)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or_else(|| StatusCode::NOT_FOUND)?;
let player_a_id = player_a.id;
let player_a_wenglin_before = player_a.wenglin.clone();
log::trace!("Finding player B's info...");
let player_b = Player::get_by_id(&mut conn, matchii.player_b)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or_else(|| StatusCode::NOT_FOUND)?;
let player_b_id = player_b.id;
let player_b_wenglin_before = player_b.wenglin.clone();
log::trace!("Calculating rating changes...");
let (player_a_wenglin_after, player_b_wenglin_after) = skillratings::weng_lin::weng_lin(
&player_a_wenglin_before.0,
&player_b_wenglin_before.0,
&outcome.into(),
&WengLinConfig::default(),
);
let player_a_wenglin_after = WengLinRating(player_a_wenglin_after);
log::trace!("A's new rating is: {player_a_wenglin_after:?}");
let player_b_wenglin_after = WengLinRating(player_b_wenglin_after);
log::trace!("B's new rating is: {player_b_wenglin_after:?}");
log::trace!("Starting database transaction...");
let (r#match, player_a, player_b) = conn.transaction(|tx| {
log::trace!("Updating A's rating...");
let player_a = player_a.update_wenglin(tx, &player_a_wenglin_after)
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
log::trace!("Updating B's rating...");
let player_b = player_b.update_wenglin(tx, &player_b_wenglin_after)
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
log::trace!("Inserting match...");
let matchi = MatchI {
name,
player_a_id,
player_a_wenglin_before,
player_a_wenglin_after,
player_b_id,
player_b_wenglin_before,
player_b_wenglin_after,
outcome,
};
let r#match = matchi.insert(tx)
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
Ok::<(Match, Player, Player), anyhow::Error>((r#match, player_a, player_b))
})
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log::trace!("Preparing send message future...");
let chat = config::TELEGRAM_NOTIFICATION_CHAT_ID();
let chat = ChatId(*chat);
let mut send_message_future = bot.send_message(chat, match_to_text(&r#match, &player_a, &player_b));
let topic = config::TELEGRAM_NOTIFICATION_TOPIC_ID();
if let Some(topic) = topic {
let topic = MessageId(*topic);
let topic = ThreadId(topic);
send_message_future.message_thread_id = Some(topic);
}
log::trace!("Sending message...");
let _message = send_message_future.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(r#match))
}

View file

@ -2,14 +2,37 @@ use axum::extract::Path;
use axum::http::StatusCode;
use axum::Json;
use diesel::{Connection, PgConnection};
use serde::{Deserialize, Serialize};
use model::Player;
use crate::config;
use crate::database::model;
use crate::database::model::TelegramId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerO {
id: i32,
telegram_id: Option<TelegramId>,
username: String,
human_score: Option<i64>,
}
impl From<Player> for PlayerO {
fn from(value: Player) -> Self {
Self {
id: value.id,
telegram_id: value.telegram_id,
username: value.username,
human_score: match value.competitive {
true => Some(value.wenglin.human_score()),
false => None
},
}
}
}
#[axum::debug_handler]
pub async fn get_all()
-> Result<Json<Vec<Player>>, StatusCode>
-> Result<Json<Vec<PlayerO>>, StatusCode>
{
let mut conn = PgConnection::establish(config::DATABASE_URL())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -17,14 +40,14 @@ pub async fn get_all()
let players = Player::all(&mut conn)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(players))
Ok(Json(players.into_iter().map(Into::into).collect()))
}
#[axum::debug_handler]
pub async fn get_by_id(
Path(player_id): Path<i32>,
)
-> Result<Json<Player>, StatusCode>
-> Result<Json<PlayerO>, StatusCode>
{
let mut conn = PgConnection::establish(config::DATABASE_URL())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -33,14 +56,14 @@ pub async fn get_by_id(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(player))
Ok(Json(player.into()))
}
#[axum::debug_handler]
pub async fn get_by_telegram_id(
Path(telegram_id): Path<TelegramId>,
)
-> Result<Json<Player>, StatusCode>
-> Result<Json<PlayerO>, StatusCode>
{
let mut conn = PgConnection::establish(config::DATABASE_URL())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -49,5 +72,5 @@ pub async fn get_by_telegram_id(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(player))
Ok(Json(player.into()))
}

View file

@ -1 +0,0 @@
pub mod webapp;

View file

@ -1,8 +0,0 @@
use teloxide::types::WebAppData;
pub async fn process_data(
web_app_data: WebAppData,
) -> anyhow::Result<()> {
log::trace!("{web_app_data:#?}");
Ok(())
}

View file

@ -1,7 +1,5 @@
import {NextConfig} from "next"
const nextConfig: NextConfig = {
output: "export",
};
const nextConfig: NextConfig = {};
export default nextConfig;

View file

@ -0,0 +1,3 @@
export default async function Page() {
}

View file

@ -0,0 +1,16 @@
import {ReportBox} from "@/components/ReportBox"
import {ReportBoxInteractive} from "@/components/ReportBoxInteractive"
import {PlayerO} from "@/holycow"
export default async function Page({params: {telegramId}}) {
const playersResponse = await fetch(`${process.env.BASE_URL}/api/results/`)
const players: PlayerO[] = await playersResponse.json()
const playerA: PlayerO = players.find(p => p.telegram_id == telegramId)
return (
<ReportBoxInteractive
players={players}
playerA={playerA}
/>
)
}

View file

@ -1,18 +1,38 @@
"use client";
import { useTelegram } from "@/components/useTelegram";
import {useEffect, useMemo} from "react"
import {LoadingBox} from "@/components/LoadingBox"
import {useTelegram} from "@/components/useTelegram"
import {useEffect} from "react"
import {useRouter} from "next/navigation"
export default function Page() {
const router = useRouter()
const telegram = useTelegram()
const telegramData = telegram?.WebApp?.initDataUnsafe
const userId = telegramData?.user?.id
const userName = telegramData?.user?.first_name
const startParam = telegramData?.start_param
const data = telegram?.WebApp?.initDataUnsafe
const startParam = data?.start_param
const resultsData = undefined
const resultsError = undefined
return <main>
</main>
useEffect(
() => {
switch(startParam) {
case undefined:
return
case "profile":
router.replace(`/${data.user.id}/profile`)
return
case "report":
router.replace(`/${data.user.id}/report`)
return
default:
router.replace(`/error404`)
return
}
},
[startParam]
)
return (
<LoadingBox>
Connecting to Telegram...
</LoadingBox>
)
}

View file

@ -1,107 +0,0 @@
"use client";
import { useTelegram } from "@/components/useTelegram";
import classNames from "classnames";
import {FormEvent, useCallback, useMemo, useState} from "react"
export default function Page() {
const telegram = useTelegram()
const telegramData = telegram?.WebApp?.initDataUnsafe
const userId = telegramData?.user?.id
const userName = telegramData?.user?.first_name ?? "???"
const [opponent, setOpponent] = useState("")
const [result, setResult] = useState(null)
const onSubmit = useCallback(
(e: FormEvent) => {
telegram?.WebApp?.sendData?.(`${result} ${opponent}`)
},
[telegram, result, opponent]
)
const contents = useMemo(() => {
return (
<div className={"chapter-1"}>
<form
className={classNames({
"panel": true,
"box": true,
"form-flex": true,
"red": result === "L",
"yellow": result === "T",
"green": result === "W",
})}
onSubmit={onSubmit}
>
<h3>
Registra risultato
</h3>
<label>
<span>Tu</span>
<div>
{userName}
</div>
<span></span>
</label>
<label>
<span>Avversario</span>
<select onChange={(e) => setOpponent(e.target.value)} value={opponent}>
<option value={""}></option>
<option value={"34053709"}>@AleCose</option>
<option value={"843330513"}>@Alleander</option>
<option value={"200821462"}>@catstolker</option>
<option value={"454281712"}>@CookieSin</option>
<option value={"524944901"}>@druidsfluid</option>
<option value={"48371848"}>@Francesco_Cuoghi</option>
<option value={"148374774"}>@GioOmbra</option>
<option value={"19611986"}>@GoodBalu</option>
<option value={"131057096"}>@Malbyx</option>
<option value={"488463576"}>@Mallllco</option>
<option value={"33523022"}>@MaxBubblegum</option>
<option value={"139079908"}>@SnowyCoder</option>
<option value={"165792255"}>@Spaggia</option>
<option value={"25167391"}>@Steffo</option>
<option value={"890339572"}>@xZefyr</option>
<option value={"19097832"}>@zezelda</option>
</select>
<span></span>
</label>
<div className={"form-flex-choice"}>
<span>Risultato</span>
<div>
<label>
<input type={"radio"} name={"result"} value={"W"} onChange={(e) => setResult(e.target.value)} checked={result === "W"}/> Vittoria
</label>
<label>
<input type={"radio"} name={"result"} value={"T"} onChange={(e) => setResult(e.target.value)} checked={result === "T"}/> Pareggio
</label>
<label>
<input type={"radio"} name={"result"} value={"L"} onChange={(e) => setResult(e.target.value)} checked={result === "L"}/> Sconfitta
</label>
</div>
<span></span>
</div>
<input
type={"submit"}
value={"Invia"}
className={classNames({
// TODO: "fade": result === null || opponent === ""
"fade": true,
})}
disabled={
// TODO: result === null || opponent === ""
true
}
/>
</form>
</div>
)
}, [onSubmit, userName, opponent, result])
return <>
<main>
{contents}
</main>
</>
}

View file

@ -0,0 +1,13 @@
@keyframes loading {
0% {
opacity: 0.25;
}
100% {
opacity: 1.00;
}
}
.loading {
animation: loading 0.5s infinite alternate ease-in-out;
}

View file

@ -0,0 +1,19 @@
import classNames from "classnames"
import {ReactNode} from "react"
import style from "./LoadingBox.module.css"
export type LoadingBoxProps = {
children?: ReactNode,
}
export function LoadingBox({children = "Loading..."}: LoadingBoxProps) {
return (
<div className={classNames({
"panel": true,
"box": true,
[style.loading]: true
})}>
{children}
</div>
)
}

View file

@ -0,0 +1,97 @@
import {Outcome, PlayerO} from "@/holycow"
import classNames from "classnames"
import {FormEvent} from "react"
export type ReportBoxProps = {
players: PlayerO[],
playerA: PlayerO,
playerB?: PlayerO,
setPlayerB: (player?: PlayerO) => void,
outcome: Outcome,
setOutcome: (outcome: Outcome) => void,
name: string,
setName: (name: string) => void,
onSubmit: (e: FormEvent) => void,
}
// TODO
export function ReportBox({players, playerA, playerB, setPlayerB, outcome, setOutcome, name, setName, onSubmit}: ReportBoxProps) {
return (
<form
className={classNames({
"panel": true,
"box": true,
"form-flex": true,
"green": outcome === Outcome.AWins,
"red": outcome === Outcome.BWins,
"yellow": outcome === Outcome.Tie,
})}
onSubmit={e => {
e.preventDefault()
onSubmit(e)
}}
>
<h3>
Registra risultato
</h3>
<p></p>
<label>
<span>Tu</span>
<div>
{playerA.username}
</div>
<span>{playerA.human_score && `${playerA.human_score}`}</span>
</label>
<label>
<span>Avversario</span>
<select onChange={e => setPlayerB(players.find(p => p.id == Number.parseInt(e.target.value)))} value={playerB?.id}>
<option value={undefined}></option>
{players
.filter(player => player.id !== playerA.id)
.map(player => (
<option key={player.id} value={player.id}>{player.username}</option>
))
}
</select>
<span>{playerB && playerB.human_score && `${playerB.human_score}`}</span>
</label>
<p></p>
<div className={"form-flex-choice"}>
<span>Risultato</span>
<div>
<label>
<input type={"radio"} name={"result"} value={"AWins"} checked={outcome === Outcome.AWins} onChange={e => e.target.checked && setOutcome(Outcome.AWins)}/> Vittoria
</label>
<label>
<input type={"radio"} name={"result"} value={"Tie"} checked={outcome === Outcome.Tie} onChange={e => e.target.checked && setOutcome(Outcome.Tie)}/> Pareggio
</label>
<label>
<input type={"radio"} name={"result"} value={"BWins"} checked={outcome === Outcome.BWins} onChange={e => e.target.checked && setOutcome(Outcome.BWins)}/> Sconfitta
</label>
</div>
<span></span>
</div>
<p></p>
<label>
<span>Titolo sfida</span>
<input type={"text"} onChange={e => setName(e.target.value)} value={name}/>
<small>(opzionale)</small>
</label>
<p></p>
<input
type={"submit"}
value={"Invia"}
className={classNames({
"fade": outcome === undefined || playerB === undefined,
})}
disabled={
outcome === undefined || playerB === undefined
}
/>
</form>
)
}

View file

@ -0,0 +1,56 @@
"use client";
import {ReportBox} from "@/components/ReportBox"
import {useTelegram} from "@/components/useTelegram"
import {Outcome, PlayerO} from "@/holycow"
import {useCallback, useState} from "react"
export type ReportBoxInteractiveProps = {
players: PlayerO[],
playerA: PlayerO,
}
export function ReportBoxInteractive({players, playerA}: ReportBoxInteractiveProps) {
const [playerB, setPlayerB] = useState<PlayerO | undefined>(undefined)
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined)
const [name, setName] = useState<string>("")
const [running, setRunning] = useState<boolean>(false)
const telegram = useTelegram()
const onSubmit = useCallback(
() => {
if(!telegram) return
setRunning(true)
const body = JSON.stringify({
name: name === "" ? null : name,
player_a: playerA.id,
player_b: playerB.id,
outcome: outcome.toString(),
})
fetch("/api/matches/", {
method: "POST",
body,
headers: {
"Content-Type": "application/json"
}
}).finally(() => {
telegram?.WebApp?.close?.()
})
},
[telegram, name, playerA, playerB, outcome]
)
return (
<ReportBox
players={players}
playerA={playerA}
playerB={playerB}
setPlayerB={setPlayerB}
outcome={outcome}
setOutcome={setOutcome}
name={name}
setName={setName}
onSubmit={running ? () => {} : onSubmit}
/>
)
}

View file

@ -0,0 +1,12 @@
export enum Outcome {
AWins = "AWins",
BWins = "BWins",
Tie = "Tie",
}
export type PlayerO = {
id: number,
telegram_id: number,
username: string,
human_score: null | number,
}

View file

@ -5,6 +5,7 @@ interface Telegram {
interface TelegramWebApp {
initDataUnsafe?: TelegramWebAppInitData
sendData?: (data: string) => void,
close?: () => void,
}
interface TelegramWebAppInitData {