diff --git a/.gitignore b/.gitignore index bf94a3e..90f4f79 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ config.json ticker_config.json mail.* +/.vs +/.codex diff --git a/blank_tournament/display_court_displaysettings b/blank_tournament/display_court_displaysettings new file mode 100644 index 0000000..a2853cf --- /dev/null +++ b/blank_tournament/display_court_displaysettings @@ -0,0 +1,19 @@ +{"client_id":"201","hostname":"Android-5","court_id":"default_1","displaysetting_id":"default_1 _1746279411442","_id":"4fOwaXIid6Aby102"} +{"client_id":"163","hostname":"display03","court_id":"default_3","displaysetting_id":"default_1 _1746279411442","_id":"58ko1WCwNVgeXZez"} +{"client_id":"209","hostname":"Tablet09","court_id":null,"displaysetting_id":"default_1 _1746279411442","_id":"AjeZXlFDktXuGuDV"} +{"client_id":"162","hostname":"display02","court_id":"default_2","displaysetting_id":"default_1 _1746279411442","_id":"Ar4cbZOWOnexWqbT"} +{"client_id":"168","hostname":"display08","court_id":"default_8","displaysetting_id":"default_1 _1746279411442","_id":"FqQdFy0ZZhrjz5jy"} +{"client_id":"166","hostname":"display06","court_id":"default_6","displaysetting_id":"default_1 _1746279411442","_id":"G0kDkTUNLlfHIBxU"} +{"client_id":"211","hostname":"Tablet11","court_id":null,"displaysetting_id":"default_1 _1746279411442","_id":"J0JXHgZh9tJbQHML"} +{"client_id":"161","hostname":"display01","court_id":"default_1","displaysetting_id":"default_1 _1746279411442","_id":"KAm9UyBesxxEQwCH"} +{"client_id":"204","hostname":"Android-2","court_id":"default_4","displaysetting_id":"default_1 _1746279411442","_id":"LruIunhfT22Pif01"} +{"client_id":"203","hostname":"Android","court_id":"default_3","displaysetting_id":"default_1 _1746279411442","_id":"O7GfGFfRtWP0bZ9E"} +{"client_id":"207","hostname":"Tablet07","court_id":"default_7","displaysetting_id":"default_1 _1746279411442","_id":"T0uPyYbLu7ZEbwTE"} +{"client_id":"212","hostname":"Tablet12","court_id":null,"displaysetting_id":"default_1 _1746279411442","_id":"X6DKVYegQLVFQulX"} +{"client_id":"202","hostname":"Android","court_id":"default_2","displaysetting_id":"default_1 _1746279411442","_id":"XR8v0Vgyrd0uI7Gb"} +{"client_id":"210","hostname":"Tablet10","court_id":null,"displaysetting_id":"default_1 _1746279411442","_id":"iKUjQjaauJzh45me"} +{"client_id":"164","hostname":"display04","court_id":"default_4","displaysetting_id":"default_1 _1746279411442","_id":"jBEO6pSWvkOFvsKV"} +{"client_id":"206","hostname":"Android-3","court_id":"default_6","displaysetting_id":"default_1 _1746279411442","_id":"o7pbXdlGLU8HYxtQ"} +{"client_id":"205","hostname":"Android-4","court_id":"default_5","displaysetting_id":"default_1 _1746279411442","_id":"qDVxBuPVBF0tTUXp"} +{"client_id":"165","hostname":"display05","court_id":"default_5","displaysetting_id":"default_1 _1746279411442","_id":"qFktmzgZP4RLWyBC"} +{"client_id":"208","hostname":"Tablet08","court_id":"default_8","displaysetting_id":"default_1 _1746279411442","_id":"uw6f1RQOLqBPCVOI"} diff --git a/blank_tournament/displaysettings b/blank_tournament/displaysettings new file mode 100644 index 0000000..462080e --- /dev/null +++ b/blank_tournament/displaysettings @@ -0,0 +1,2 @@ +{"fullscreen_ask":"auto","show_announcements":"all","umpire_name":"","service_judge_name":"","court_id":"default_1","court_description":"","network_timeout":"10000","network_update_interval":"10000","displaymode_update_interval":500,"d_c0":"#50e87d","d_cb0":"#000000","d_c1":"#f76a23","d_cb1":"#000000","d_cbg":"#000000","d_cfg":"#ffffff","d_cfgdark":"#000000","d_cbg2":"#d9d9d9","d_cbg3":"#252525","d_cbg4":"#404040","d_cfg2":"#aaaaaa","d_cfg3":"#cccccc","d_cexp":"#ff0000","d_cborder":"#444444","d_ct":"#80ff00","d_ctim_blue":"#0070c0","d_ctim_active":"#ffc000","d_cserv":"#fff200","d_cserv2":"#dba766","d_crecv":"#707676","d_scale":"100","d_team_colors":false,"d_show_pause":true,"d_show_court_number":true,"d_show_competition":true,"d_show_round":true,"d_show_middle_name":false,"d_show_doubles_receiving":false,"settings_autohide":30000,"dads_interval":20000,"dads_wait":60000,"dads_dtime":10000,"dads_atime":10000,"dads_utime":"14:00","dads_mode":"none","double_click_timeout":1000,"button_block_timeout":"1200","negative_timers":false,"shuttle_counter":true,"language":"de","editmode_doubleclick":false,"displaymode_style":"tournamentcourt","displaymode_court_id":"default_1","wakelock":"display","click_mode":"auto","refmode_client_enabled":false,"refmode_client_ws_url":"wss://live.aufschlagwechsel.de/refmode_hub/","refmode_referee_ws_url":"wss://live.aufschlagwechsel.de/refmode_hub/","refmode_client_node_name":"","referee_service_judges":false,"settings_style":"complete","id":"default_default_1 _1746279900198","_id":"hsBi45n92LaUndK1","advertisements":[],"description":"TV","devicemode":"display","d_cfg4":"#000000","style":"complete"} +{"fullscreen_ask":"never","show_announcements":"none","umpire_name":"","service_judge_name":"","court_id":1,"court_description":"","network_timeout":"10000","network_update_interval":"10000","displaymode_update_interval":500,"d_c0":"#50e87d","d_cb0":"#000000","d_c1":"#f76a23","d_cb1":"#000000","d_cbg":"#000000","d_cfg":"#ffffff","d_cfgdark":"#000000","d_cbg2":"#d9d9d9","d_cbg3":"#252525","d_cbg4":"#404040","d_cfg2":"#aaaaaa","d_cfg3":"#cccccc","d_cexp":"#ff0000","d_cborder":"#444444","d_ct":"#80ff00","d_ctim_blue":"#0070c0","d_ctim_active":"#ffc000","d_cserv":"#fff200","d_cserv2":"#dba766","d_crecv":"#707676","d_scale":"100","d_team_colors":false,"d_show_pause":true,"d_show_court_number":true,"d_show_competition":true,"d_show_round":true,"d_show_middle_name":false,"d_show_doubles_receiving":false,"settings_autohide":30000,"dads_interval":20000,"dads_wait":60000,"dads_dtime":10000,"dads_atime":10000,"dads_utime":"14:00","dads_mode":"none","double_click_timeout":1000,"button_block_timeout":"1000","negative_timers":true,"shuttle_counter":false,"language":"de","editmode_doubleclick":true,"displaymode_style":"tournamentcourt","displaymode_court_id":1,"wakelock":"display","click_mode":"auto","refmode_client_enabled":false,"refmode_client_ws_url":"wss://live.aufschlagwechsel.de/refmode_hub/","refmode_referee_ws_url":"wss://live.aufschlagwechsel.de/refmode_hub/","refmode_client_node_name":"","referee_service_judges":false,"settings_style":"default","id":"default_1 _1746279411442","_id":"okgn30wBUbGHQ5YX","description":"Tablet","devicemode":"umpire","d_cfg4":"#000000","style":"hidden"} diff --git a/blank_tournament/normalizations b/blank_tournament/normalizations new file mode 100644 index 0000000..1f1e570 --- /dev/null +++ b/blank_tournament/normalizations @@ -0,0 +1,4 @@ +{"origin":"oluek","replace":"oluaek","language":"de-DE","_id":"jgK0c7WA6ZJyhgr6"} +{"origin":"Roschild","replace":"Rohschild","language":"de-DE","_id":"jgK0c7WA6ZJyhgr7"} +{"origin":" Ly","replace":" Lee","language":"de-DE","_id":"jgK0c7WA6ZJyhgr8"} +{"origin":"sy","replace":"si","language":"de-DE","_id":"jgK0c7WA6ZJyhgr9"} diff --git a/blank_tournament/tournaments b/blank_tournament/tournaments new file mode 100644 index 0000000..79dd09e --- /dev/null +++ b/blank_tournament/tournaments @@ -0,0 +1,2 @@ +{"key":"default","_id":"1sy6ogH6y1HRtyqL","name":"Change Me!","btp_enabled":true,"btp_autofetch_enabled":true,"btp_readonly":false,"btp_ip":"192.168.16.253","btp_password":"test#789","is_team":false,"is_nation_competition":true,"only_now_on_court":true,"warmup":"call-down","ticker_enabled":false,"ticker_url":"","ticker_password":"","language":"de","dm_style":"international","btp_settings":{"check_in_per_match":false,"pause_duration_ms":900000},"warmup_ready":"150","warmup_start":"180","tabletoperator_with_umpire_enabled":false,"tabletoperator_enabled":true,"tabletoperator_winner_of_quaterfinals_enabled":true,"tabletoperator_use_manual_counting_boards_enabled":false} +{"$$indexCreated":{"fieldName":"key","unique":true,"sparse":false}} diff --git a/bts/admin.js b/bts/admin.js index 56876b9..08d1699 100644 --- a/bts/admin.js +++ b/bts/admin.js @@ -7,10 +7,12 @@ const uuidv4 = require('uuid/v4'); const {promisify} = require('util'); const btp_manager = require('./btp_manager'); +const update_queue = require('./update_queue'); const serror = require('./serror'); const stournament = require('./stournament'); const ticker_manager = require('./ticker_manager'); const utils = require('./utils'); +const match_automation = require('./match_automation'); /** @@ -41,6 +43,17 @@ function handle_tournament_list(app, ws, msg) { }); } +function handle_confirm_match_finished(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament' }); + } + if (!msg.court_id) { + return ws.respond(msg, { message: 'Missing court' }); + } + const bupws = require('./bupws'); + bupws.send_finshed_confirmed(app, msg.tournament_key, msg.court_id); +} + function handle_tournament_edit_props(app, ws, msg) { if (! msg.key) { return ws.respond(msg, {message: 'Missing key'}); @@ -51,32 +64,304 @@ function handle_tournament_edit_props(app, ws, msg) { const key = msg.key; const props = utils.pluck(msg.props, [ - 'name', + 'name','tguid', + 'automation_enabled', 'btp_enabled', 'btp_autofetch_enabled', 'btp_readonly', - 'btp_ip', 'btp_password', - 'is_team', 'is_nation_competition', 'only_now_on_court', + 'btp_ip', 'btp_password','btp_autofetch_timeout_intervall', + 'is_team', 'is_nation_competition', + 'warmup', 'warmup_ready', 'warmup_start', + 'upcoming_matches_animation_speed', 'upcoming_matches_max_count','upcoming_matches_animation_pause', + 'self_check_in_called_overlay_duration_ms', 'ticker_enabled', 'ticker_url', 'ticker_password', - 'language', 'dm_style', - 'logo_background_color', 'logo_foreground_color']); + 'language', 'dm_style', 'displaysettings_general', + 'tabletoperator_enabled', 'tabletoperator_break_seconds', + 'announcement_speed','announcement_pause_time_ms', + 'tabletoperator_set_break_after_tabletservice','tabletoperator_with_state_enabled', + 'tabletoperator_with_state_from_match_enabled', + 'tabletoperator_winner_of_quaterfinals_enabled','tabletoperator_split_doubles', + 'tabletoperator_use_manual_counting_boards_enabled', 'tabletoperator_with_umpire_enabled', + 'annoncement_include_event', 'annoncement_include_round','annoncement_include_matchnumber', + 'preparation_meetingpoint_enabled', 'preparation_tabletoperator_setup_enabled', + 'call_preparation_matches_automatically_enabled', 'call_next_possible_scheduled_match_in_preparation', + 'preparation_successor_rally_count', + 'preparation_call_time_limit_before_scheduled_enabled', + 'preparation_call_time_limit_before_scheduled_minutes', + 'preparation_call_block_ahead_limit_enabled', + 'preparation_call_block_ahead_limit', + 'preparation_call_time_ahead_of_frontier_enabled', + 'preparation_call_time_ahead_of_frontier_minutes', + 'preparation_call_matches_ahead_of_frontier_enabled', + 'preparation_call_matches_ahead_of_frontier_limit', + 'preparation_call_player_pause_expired_enabled', + 'preparation_call_technical_officials_available_enabled', + 'call_on_court_time_limit_before_scheduled_enabled', + 'call_on_court_time_limit_before_scheduled_minutes', + 'call_on_court_only_preparation_enabled', + 'call_on_court_only_preparation_minutes', + 'call_on_court_block_ahead_limit_enabled', + 'call_on_court_block_ahead_limit', + 'call_on_court_time_ahead_of_frontier_enabled', + 'call_on_court_time_ahead_of_frontier_minutes', + 'call_on_court_matches_ahead_of_frontier_enabled', + 'call_on_court_matches_ahead_of_frontier_limit', + 'call_on_court_participant_readiness_mode', + 'call_on_court_player_pause_expired_enabled', + 'call_on_court_technical_officials_mode', + 'call_on_court_require_official_space_enabled', + 'official_rotation_mode', + 'technical_official_auto_assignment_mode', + 'technical_official_break_after_assignment_seconds', + 'logo_background_color', 'logo_foreground_color', 'scoring_formats']); if (msg.props.btp_timezone) { props.btp_timezone = msg.props.btp_timezone === 'system' ? undefined : msg.props.btp_timezone; } + app.db.tournaments.findOne({ key }, async (err, tournament) => { + if (err || !tournament) { + ws.respond(msg, err); + return; + } + app.db.tournaments.update({ key }, { $set: props }, { returnUpdatedDocs: true }, function (err, num, t) { + if (err) { + ws.respond(msg, err); + return; + } + if (utils.has_key(props, k => /^btp_/.test(k))) { + btp_manager.reconfigure(app, t); + } + if (utils.has_key(props, k => /^ticker_/.test(k))) { + ticker_manager.reconfigure(app, t); + } + notify_change(app, key, 'props', t); + if (utils.has_key(props, (k) => k === 'technical_official_auto_assignment_mode' || k === 'official_rotation_mode')) { + const match_utils = require('./match_utils'); + match_utils.queue_auto_assign_technical_officials_when_available(app, key); + } + if (utils.has_key(props, (k) => k === 'technical_official_break_after_assignment_seconds')) { + const match_utils = require('./match_utils'); + match_utils.queue_process_expired_technical_official_breaks(app, key); + } + if (props.automation_enabled === true) { + const match_utils = require('./match_utils'); + match_utils.queue_auto_assign_technical_officials_when_available(app, key); + match_utils.queue_auto_execute_preparation_selections(app, key, (selectionErr) => { + if (selectionErr) { + console.warn('[bts] failed to resume preparation automation', selectionErr && (selectionErr.stack || selectionErr.message || String(selectionErr))); + return; + } + match_utils.auto_call_matches_on_free_courts(app, key, (callErr) => { + if (callErr) { + console.warn('[bts] failed to resume on-court automation', callErr && (callErr.stack || callErr.message || String(callErr))); + } + }); + }); + } - app.db.tournaments.update({key}, {$set: props}, {returnUpdatedDocs: true}, function(err, num, t) { - if (err) { + if (!tournament.displaysettings_general || (tournament.displaysettings_general != t.displaysettings_general)){ + + const bupws = require('./bupws'); + bupws.change_default_display_mode(app, t, tournament.displaysettings_general, t.displaysettings_general); + } + + ws.respond(msg, err); + }); + }); +} + +function handle_tournament_edit_prop(app, ws, msg) { + if (! msg.key) { + return ws.respond(msg, {message: 'Missing key'}); + } + if (typeof msg.field === 'undefined') { + return ws.respond(msg, {message: 'Missing field'}); + } + + const allowed_fields = new Set([ + 'name', 'tguid', + 'automation_enabled', + 'btp_enabled', 'btp_autofetch_enabled', 'btp_readonly', + 'btp_ip', 'btp_password', 'btp_autofetch_timeout_intervall', 'btp_timezone', + 'is_team', 'is_nation_competition', + 'warmup', 'warmup_ready', 'warmup_start', + 'upcoming_matches_animation_speed', 'upcoming_matches_max_count', 'upcoming_matches_animation_pause', + 'self_check_in_called_overlay_duration_ms', + 'ticker_enabled', 'ticker_url', 'ticker_password', + 'language', 'dm_style', 'displaysettings_general', + 'tabletoperator_enabled', 'tabletoperator_break_seconds', + 'announcement_speed', 'announcement_pause_time_ms', + 'tabletoperator_set_break_after_tabletservice', 'tabletoperator_with_state_enabled', + 'tabletoperator_with_state_from_match_enabled', + 'tabletoperator_winner_of_quaterfinals_enabled', 'tabletoperator_split_doubles', + 'tabletoperator_use_manual_counting_boards_enabled', 'tabletoperator_with_umpire_enabled', + 'annoncement_include_event', 'annoncement_include_round', 'annoncement_include_matchnumber', + 'preparation_meetingpoint_enabled', 'preparation_tabletoperator_setup_enabled', + 'call_preparation_matches_automatically_enabled', 'call_next_possible_scheduled_match_in_preparation', + 'preparation_successor_rally_count', + 'preparation_call_time_limit_before_scheduled_enabled', + 'preparation_call_time_limit_before_scheduled_minutes', + 'preparation_call_block_ahead_limit_enabled', + 'preparation_call_block_ahead_limit', + 'preparation_call_time_ahead_of_frontier_enabled', + 'preparation_call_time_ahead_of_frontier_minutes', + 'preparation_call_matches_ahead_of_frontier_enabled', + 'preparation_call_matches_ahead_of_frontier_limit', + 'preparation_call_player_pause_expired_enabled', + 'preparation_call_technical_officials_available_enabled', + 'call_on_court_time_limit_before_scheduled_enabled', + 'call_on_court_time_limit_before_scheduled_minutes', + 'call_on_court_only_preparation_enabled', + 'call_on_court_only_preparation_minutes', + 'call_on_court_block_ahead_limit_enabled', + 'call_on_court_block_ahead_limit', + 'call_on_court_time_ahead_of_frontier_enabled', + 'call_on_court_time_ahead_of_frontier_minutes', + 'call_on_court_matches_ahead_of_frontier_enabled', + 'call_on_court_matches_ahead_of_frontier_limit', + 'call_on_court_participant_readiness_mode', + 'call_on_court_player_pause_expired_enabled', + 'call_on_court_technical_officials_mode', + 'call_on_court_require_official_space_enabled', + 'official_rotation_mode', + 'technical_official_auto_assignment_mode', + 'technical_official_break_after_assignment_seconds', + 'logo_background_color', 'logo_foreground_color', + ]); + + const field = msg.field; + if (!allowed_fields.has(field)) { + return ws.respond(msg, {message: 'Unsupported field ' + field}); + } + + const key = msg.key; + let value = msg.value; + if (field === 'btp_timezone') { + value = value === 'system' ? undefined : value; + } + const props = {}; + props[field] = value; + + app.db.tournaments.findOne({ key }, async (err, tournament) => { + if (err || !tournament) { ws.respond(msg, err); return; } - if (utils.has_key(props, k => /^btp_/.test(k))) { - btp_manager.reconfigure(app, t); + app.db.tournaments.update({ key }, { $set: props }, { returnUpdatedDocs: true }, function (err, num, t) { + if (err) { + ws.respond(msg, err); + return; + } + if (/^btp_/.test(field)) { + btp_manager.reconfigure(app, t); + } + if (/^ticker_/.test(field)) { + ticker_manager.reconfigure(app, t); + } + if (field === 'automation_enabled' && t[field] === true) { + const match_utils = require('./match_utils'); + match_utils.queue_auto_assign_technical_officials_when_available(app, key); + match_utils.queue_auto_execute_preparation_selections(app, key, (selectionErr) => { + if (selectionErr) { + console.warn('[bts] failed to resume preparation automation', selectionErr && (selectionErr.stack || selectionErr.message || String(selectionErr))); + return; + } + match_utils.auto_call_matches_on_free_courts(app, key, (callErr) => { + if (callErr) { + console.warn('[bts] failed to resume on-court automation', callErr && (callErr.stack || callErr.message || String(callErr))); + } + }); + }); + } + notify_change(app, key, 'prop_changed', { field, value: t[field] }); + + if (!tournament.displaysettings_general || (field === 'displaysettings_general' && tournament.displaysettings_general != t.displaysettings_general)){ + const bupws = require('./bupws'); + bupws.change_default_display_mode(app, t, tournament.displaysettings_general, t.displaysettings_general); + } + + ws.respond(msg, err); + }); + }); +} + +function handle_tournament_edit_scoring_format(app, ws, msg) { + if (! msg.key) { + return ws.respond(msg, {message: 'Missing key'}); + } + if (! msg.scoring_format) { + return ws.respond(msg, {message: 'Missing scoring_format'}); + } + + const key = msg.key; + const scoring_format = msg.scoring_format; + app.db.tournaments.findOne({ key }, async (err, tournament) => { + if (err || !tournament) { + ws.respond(msg, err); + return; } - if (utils.has_key(props, k => /^ticker_/.test(k))) { - ticker_manager.reconfigure(app, t); + + const btp_sync = require('./btp_sync'); + const scoring_formats = tournament.scoring_formats || { formats: [], default_id: null }; + const formats = Array.isArray(scoring_formats.formats) ? scoring_formats.formats.slice() : []; + const index = formats.findIndex(f => Number(f.id) === Number(scoring_format.id)); + if (index === -1) { + return ws.respond(msg, {message: 'Unknown scoring format ' + scoring_format.id}); } - notify_change(app, key, 'props', t); - ws.respond(msg, err); + formats[index] = btp_sync._sanitize_scoring_format(scoring_format); + const updated_scoring_formats = { + ...scoring_formats, + formats, + }; + + app.db.tournaments.update( + { key }, + { $set: { scoring_formats: updated_scoring_formats } }, + { returnUpdatedDocs: true }, + function (err) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, key, 'scoring_format_changed', { + scoring_format: formats[index], + }); + notify_change(app, key, 'props', { + scoring_formats: updated_scoring_formats, + }); + ws.respond(msg, err); + } + ); + }); +} + + +function handle_tournament_edit_logo(app, ws, msg) { + if (! msg.key) { + return ws.respond(msg, {message: 'Missing key'}); + } + if (! msg.props) { + return ws.respond(msg, {message: 'Missing props'}); + } + + const key = msg.key; + const props = utils.pluck(msg.props, [ + 'logo_background_color', 'logo_foreground_color']); + + app.db.tournaments.findOne({ key }, async (err, tournament) => { + if (err || !tournament) { + ws.respond(msg, err); + return; + } + app.db.tournaments.update({ key }, { $set: props }, { returnUpdatedDocs: true }, function (err) { + if (err) { + ws.respond(msg, err); + return; + } + + notify_change(app, key, 'logo_changed', {logo_foreground_color : props.logo_foreground_color, logo_background_color: props.logo_background_color}); + + ws.respond(msg, err); + }); }); } @@ -94,6 +379,9 @@ function handle_courts_add(app, ws, msg) { _id: tournament_key + '_' + num, tournament_key, num, + is_active: true, + has_umpire: true, + has_service_judge: true, }; }); app.db.courts.insert(added_courts, function(err) { @@ -109,22 +397,146 @@ function handle_courts_add(app, ws, msg) { }); } +function handle_court_edit(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'court_id'])) { + return; + } + + const tournament_key = msg.tournament_key; + const court_id = msg.court_id; + + const query = { + tournament_key, + _id: court_id, + }; + + app.db.courts.findOne(query, async (err, court) => { + if (err || !court) { + ws.respond(msg, err); + return; + } + const is_active = (msg.is_active != undefined ? msg.is_active : court.is_active); + const has_umpire = (msg.has_umpire != undefined ? msg.has_umpire : (court.has_umpire != undefined ? court.has_umpire : true)); + const has_service_judge = (msg.has_service_judge != undefined ? msg.has_service_judge : (court.has_service_judge != undefined ? court.has_service_judge : true)); + app.db.courts.update(query, { $set: {is_active, has_umpire, has_service_judge} }, {}, (err) => { + if(err) { + ws.respond(msg, err); + return; + } + notify_change(app, msg.tournament_key, 'court_changed', {court_id, is_active, has_umpire, has_service_judge, match_id: court.match_id ?? null}); + ws.respond(msg); + return; + }); + }); +} + +function handle_location_changed(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'location_id', 'highlight', 'preparation_addition', 'meetingpoint_announcement'])) { + return; + } + const location_id = msg.location_id; + const preparation_addition = msg.preparation_addition; + const meetingpoint_announcement = msg.meetingpoint_announcement; + const highlight = msg.highlight; + + const query = { + tournament_key: msg.tournament_key, + _id: msg.location_id, + }; + + app.db.locations.findOne(query, async (err, old_location) => { + if(err) { + ws.respond(msg, err); + return; + } + + app.db.locations.update(query, { $set: {highlight, preparation_addition, meetingpoint_announcement} }, {}, (err) => { + if(err) { + ws.respond(msg, err); + return; + } + + notify_change(app, msg.tournament_key, 'location_changed', {location_id, highlight, preparation_addition, meetingpoint_announcement}); + notify_change(app, msg.tournament_key, 'location_highlight_changed', {old_location_highlight: old_location.highlight, new_location_highlight: highlight}); + + + const match_querry = { + tournament_key: msg.tournament_key, + 'setup.highlight': old_location.highlight, + }; + app.db.matches.update( + match_querry, + { $set: { 'setup.highlight': highlight } }, + { multi: true, returnUpdatedDocs: true }, + (err, numAffected, affectedDocs) => { + if (err) { + ws.respond(msg, err); + return; + } + + const btp_manager = require('./btp_manager'); + + // Wenn mehrere Matches aktualisiert wurden: + if (Array.isArray(affectedDocs)) { + for (const match of affectedDocs) { + btp_manager.update_highlight(app, match); + } + } else if (affectedDocs) { + // Falls nur ein Match betroffen war + btp_manager.update_highlight(app, affectedDocs); + } + + ws.respond(msg); + return; + } + ); + }); + }); +} + +function generate_tournament_web_url(tournament) { + var url = ""; + if (tournament.ticker_enabled) { + url = "https://" + tournament.ticker_url.split("/")[2]; + } else { + url = "https://" + ((tournament.btp_settings && tournament.btp_settings.tournament_urn) ? tournament.btp_settings.tournament_urn : "www.turnier.de") + "/tournament" + (tournament.tguid ? "/" + tournament.tguid + "/matches" : "s/"); + } + return url; +} function handle_tournament_get(app, ws, msg) { if (! msg.key) { return ws.respond(msg, {message: 'Missing key'}); } - app.db.tournaments.findOne({key: msg.key}, function(err, tournament) { + app.db.tournaments.findOne({ key: msg.key }, function (err, tournament) { if (!err && !tournament) { - err = {message: 'No tournament ' + msg.key}; + err = { message: 'No tournament ' + msg.key }; } if (err) { ws.respond(msg, err); return; } - async.parallel([ + function (cb) { + try { + const qrcode = require('qrcode'); + + const url = generate_tournament_web_url(tournament); + qrcode.toDataURL(url, function (error, data) { + const qrCodeDataUrl = data; + tournament.mainQrCode = qrCodeDataUrl; + cb(error); + }); + } catch (error) + { } + }, function(cb) { + stournament.get_locations(app.db, tournament.key, function(err, locations) { + + tournament.locations = locations; + cb(err); + }); + }, function(cb) { stournament.get_courts(app.db, tournament.key, function(err, courts) { tournament.courts = courts; cb(err); @@ -134,12 +546,44 @@ function handle_tournament_get(app, ws, msg) { tournament.umpires = umpires; cb(err); }); + }, function (cb) { + stournament.get_tabletoperators(app.db, tournament.key, function (err, tabletoperators) { + tournament.tabletoperators = tabletoperators; + cb(err); + }); }, function(cb) { stournament.get_matches(app.db, tournament.key, function(err, matches) { tournament.matches = matches; cb(err); }); + }, function (cb) { + stournament.get_displays(app, tournament, function (err, displays) { + tournament.displays = displays; + cb(err); + }); + }, function (cb) { + stournament.get_normalizations(app.db, tournament.key, function (err, normalizations) { + tournament.normalizations = normalizations; + cb(err); + }); + }, function (cb) { + stournament.get_advertisements(app.db, tournament.key, function (err, advertisements) { + tournament.advertisements = advertisements; + cb(err); + }); + }, function (cb) { + stournament.get_displaysettings(app.db, tournament.key, function (err, displaysettings) { + tournament.displaysettings = displaysettings; + cb(err); + }); }], function(err) { + if (tournament.scoring_formats && Array.isArray(tournament.scoring_formats.formats)) { + const btp_sync = require('./btp_sync'); + tournament.scoring_formats = { + ...tournament.scoring_formats, + formats: tournament.scoring_formats.formats.map(f => btp_sync._sanitize_scoring_format(f)), + }; + } tournament.btp_status = btp_manager.get_status(tournament.key); tournament.ticker_status = ticker_manager.get_status(tournament.key); _annotate_tournament(tournament); @@ -148,6 +592,65 @@ function handle_tournament_get(app, ws, msg) { }); } +async function async_handle_preparation_selection_get(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key'])) { + return; + } + + const selections = await match_automation.fetch_all_location_preparation_selections(app, msg.tournament_key); + return ws.respond(msg, null, { + selections: selections.map((selection) => ({ + location_id: selection.location_id, + required_preparation_count: selection.required_preparation_count, + current_preparation_count: selection.current_preparation_count, + missing_preparation_count: selection.missing_preparation_count, + candidate_match_nums: selection.candidates.map((match) => match?.setup?.match_num).filter((num) => num != null), + selected_match_nums: selection.selected_matches.map((match) => match?.setup?.match_num).filter((num) => num != null), + })), + }); +} + +async function async_handle_preparation_selection_execute(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'location_id'])) { + return; + } + + const match_utils = require('./match_utils'); + try { + const called_matches = await update_queue.instance().execute(update_queue.named('preparation_selection_execute', async () => { + const tournament = await app.db.tournaments.findOne_async({ key: msg.tournament_key }); + if (!tournament) { + throw new Error('Cannot find tournament ' + msg.tournament_key); + } + + const selection = await match_automation.fetch_location_preparation_selection(app, msg.tournament_key, msg.location_id); + const called_matches = []; + + for (const match of selection.selected_matches) { + await new Promise((resolve, reject) => { + match_utils.call_match_in_preparation(app, tournament, match, msg.location_id, (err) => { + if (err) return reject(err); + called_matches.push({ + _id: match._id, + match_num: match?.setup?.match_num, + }); + resolve(null); + }); + }); + } + + return called_matches; + })); + + return ws.respond(msg, null, { + location_id: msg.location_id, + called_matches, + }); + } catch (err) { + return ws.respond(msg, err); + } +} + function handle_create_tournament(app, ws, msg) { if (! msg.key) { return ws.respond(msg, {message: 'Missing key'}); @@ -169,19 +672,31 @@ function _extract_setup(msg_setup) { 'match_name', 'match_num', 'now_on_court', - 'umpire_name', + 'umpire', 'service_judge_name', + 'service_judge', + 'highlight', 'is_doubles', + 'is_match', 'incomplete', + 'links', 'scheduled_time_str', 'scheduled_date', + 'scoring_format', + 'called_timestamp', + 'preparation_call_timestamp', + 'location_id', 'teams', + 'team_competition', + 'tabletoperators', 'override_colors', + 'warmup', + 'warmup_ready', + 'warmup_start', ]); if (!setup.match_name && setup.match_num) { setup.match_name = '# ' + setup.match_num; } - setup.counting = '3x21'; return setup; } @@ -210,136 +725,2419 @@ function handle_match_add(app, ws, msg) { }); } -function handle_match_edit(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key', 'id', 'setup'])) { - return; +function handle_normalization_add(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } - const tournament_key = msg.tournament_key; - const setup = _extract_setup(msg.setup); - // TODO get old setup, make sure no key has been removed - app.db.matches.update({_id: msg.id, tournament_key}, {$set: {setup}}, {returnUpdatedDocs: true}, function(err, numAffected, changed_match) { + + if (!msg.normalization) { + return ws.respond(msg, { message: 'Missing required normalization' }); + } + + app.db.normalizations.insert(msg.normalization, function (err, inserted_normalization) { if (err) { ws.respond(msg, err); return; } - if (numAffected !== 1) { - ws.respond(msg, new Error('Cannot find match ' + msg.id + ' of tournament ' + tournament_key + ' in database')); - return; - } - if (changed_match._id !== msg.id) { - const errmsg = 'Match ' + changed_match._id + ' changed by accident, intended to change ' + msg.id + ' (old nedb version?)'; - serror.silent(errmsg); - ws.respond(msg, new Error(errmsg)); - return; - } - - notify_change(app, tournament_key, 'match_edit', {match__id: msg.id, setup}); - if (msg.btp_update) { - btp_manager.update_score(app, changed_match); - } - ws.respond(msg, err); + notify_change(app, msg.tournament_key, 'normalization_add', { normalization: inserted_normalization }); }); } - -async function async_handle_match_delete(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key', 'id'])) { - return; +function handle_normalization_remove(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } - const tournament_key = msg.tournament_key; - let num_removed; - try { - num_removed = await app.db.matches.remove_async({_id: msg.id, tournament_key}, {}); - } catch (err) { - ws.respond(msg, err); - return; + + if (!msg.normalization_id) { + return ws.respond(msg, { message: 'Missing required normalization' }); } - if (num_removed !== 1) { - ws.respond(msg, new Error('Cannot find match ' + msg.id + ' of tournament ' + tournament_key + ' to remove in database')); + + const query = { _id: msg.normalization_id }; + app.db.normalizations.remove(query, {}, (err) => { + notify_change(app, msg.tournament_key, 'normalization_removed', {normalization_id: msg.normalization_id}); return; + }); +} +function handle_advertisement_add(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } - await app.db.courts.update_async({match_id: msg.id}, {$unset: {match_id: true}}, {}); + if (!msg.advertisement) { + return ws.respond(msg, { message: 'Missing required advertisement' }); + } - notify_change(app, tournament_key, 'match_delete', {match__id: msg.id}); - ws.respond(msg); + app.db.advertisements.insert(msg.advertisement, function (err, inserted_advertisement) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, msg.tournament_key, 'advertisement_add', { advertisement: inserted_advertisement }); + const bupws = require('./bupws'); + bupws.send_advertisement_add(app, msg.tournament_key,inserted_advertisement); + return; + }); } -function handle_btp_fetch(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key'])) { - return; +function handle_advertisement_remove(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } - btp_manager.fetch(msg.tournament_key); - ws.respond(msg); -} - -function handle_ticker_pushall(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key'])) { - return; + if (!msg.advertisement_id) { + return ws.respond(msg, { message: 'Missing required advertisement' }); } - ticker_manager.pushall(app, msg.tournament_key); - ws.respond(msg); + const query = { _id: msg.advertisement_id }; + app.db.advertisements.remove(query, {}, (err) => { + notify_change(app, msg.tournament_key, 'advertisement_removed', { advertisement_id: msg.advertisement_id }); + const bupws = require('./bupws'); + bupws.send_advertisement_remove(app, msg.tournament_key,msg.advertisement_id); + return; + }); } -function handle_ticker_reset(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key'])) { - return; +function handle_tabletoperator_move_up(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); + } + if (!msg.tabletoperator) { + return ws.respond(msg, { message: 'Missing tabletoperator' }); } + const tournament_key = msg.tournament_key; + const tabletoperator = msg.tabletoperator - ticker_manager.reset(app, msg.tournament_key); - ws.respond(msg); -} + const tabletoperator_querry = { 'tournament_key': msg.tournament_key, court: null }; -const all_admins = []; -function notify_change(app, tournament_key, ctype, val) { - for (const admin_ws of all_admins) { - admin_ws.sendmsg({ - type: 'change', - tournament_key, - ctype, - val, + + app.db.tabletoperators.find(tabletoperator_querry).sort({ 'start_ts': 1 }).exec((err, tabletoperators) => { + if (err) { + ws.respond(msg, err); + return; + } + + let start_ts_1 = 0; + let start_ts_2 = 0; + let index = 0; + + while (index < tabletoperators.length && tabletoperators[index]._id != tabletoperator._id) { + start_ts_2 = start_ts_1; + start_ts_1 = tabletoperators[index].start_ts; + index++; + } + app.db.tabletoperators.update({ _id: tabletoperator._id, tournament_key: tournament_key }, { $set: { start_ts: (start_ts_1 + start_ts_2)/2 } }, { returnUpdatedDocs: true}, function (err, numAffected, changed_tabletoperator) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, tournament_key, 'tabletoperator_moved_up', { tabletoperator: changed_tabletoperator }); }); - } + }); } -function handle_fetch_allscoresheets_data(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key'])) { - return; +function handle_tabletoperator_move_down(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); + } + if (!msg.tabletoperator) { + return ws.respond(msg, { message: 'Missing tabletoperator' }); } - const tournament_key = msg.tournament_key; - app.db.matches.find({ - tournament_key, - }, function(err, all_matches) { + const tabletoperator = msg.tabletoperator + + const tabletoperator_querry = { 'tournament_key': msg.tournament_key, court: null }; + + + app.db.tabletoperators.find(tabletoperator_querry).sort({ 'start_ts': -1 }).exec((err, tabletoperators) => { if (err) { - return ws.respond(msg, err); + ws.respond(msg, err); + return; } - const interesting_matches = all_matches.filter( - m => (m.presses && (m.presses.length > 0)) - ); + + let start_ts_1 = Date.now(); + let start_ts_2 = Date.now(); + let index = 0; - return ws.respond(msg, null, { - matches: interesting_matches, + while (index < tabletoperators.length && tabletoperators[index]._id != tabletoperator._id) { + start_ts_2 = start_ts_1; + start_ts_1 = tabletoperators[index].start_ts; + index++; + } + app.db.tabletoperators.update({ _id: tabletoperator._id, tournament_key: tournament_key }, { $set: { start_ts: (start_ts_1 + start_ts_2)/2 } }, { returnUpdatedDocs: true}, function (err, numAffected, changed_tabletoperator) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, tournament_key, 'tabletoperator_moved_up', { tabletoperator: changed_tabletoperator }); }); }); } - -function on_connect(app, ws) { - all_admins.push(ws); -} - -function on_close(app, ws) { - if (! utils.remove(all_admins, ws)) { - serror.silent('Removing admin ws, but it was not connected!?'); +function handle_tabletoperator_remove(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } + if (!msg.tabletoperator) { + return ws.respond(msg, { message: 'Missing tabletoperator' }); + } + const tournament_key = msg.tournament_key; + const tabletoperator = msg.tabletoperator + app.db.tabletoperators.update({ _id: tabletoperator._id, tournament_key: tournament_key }, { $set: { court: -1 } }, { returnUpdatedDocs: true}, function (err, numAffected, changed_tabletoperator) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, tournament_key, 'tabletoperator_removed', { tabletoperator: changed_tabletoperator }); + }); } -async function async_handle_tournament_upload_logo(app, ws, msg) { - if (!_require_msg(ws, msg, ['tournament_key', 'data_url'])) { - return; +function handle_tabletoperator_add(app, ws, msg) { + if (!msg.tournament_key) { + return ws.respond(msg, { message: 'Missing tournament_key' }); } - + const tournament_key = msg.tournament_key; + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return ws.respond(err); + } + + var team = null; + if (msg.match) { + const team_id = msg.team_id; + const match = msg.match + team = match.setup.teams[team_id]; + } else if (msg.tabletoperator_name) { + team = { + "players": [ + { + "asian_name": false, + "name": msg.tabletoperator_name, + "firstname": "", + "lastname": "", + "btp_id": -1 + } + ], + "name": "N/N" + }; + + } + if (team != null) { + team.players.forEach((player) => { + var tabletoperator = []; + if (tournament.tabletoperator_with_state_enabled && player.state) { + tabletoperator.push({ + "asian_name": false, + "name": player.state, + "firstname": "", + "lastname": "", + "btp_id": -1 + }); + } else { + tabletoperator.push(player); + } + const new_tabletoperator = { + tournament_key, + tabletoperator, + 'match_id': 'manually_added', + 'start_ts': Date.now(), + 'end_ts': null, + 'court': null, + 'played_on_court': null + }; + app.db.tabletoperators.insert(new_tabletoperator, function (err, inserted_tabletoperator) { + if (err) { + ws.respond(msg, err); + return; + } + notify_change(app, tournament_key, 'tabletoperator_add', { tabletoperator: inserted_tabletoperator }); + }); + }); + } else { + return ws.respond(msg, { message: 'Not enough Information to add a tabletoperator to list' }); + } + }); +} + +function handle_match_call_on_court(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'court_id', 'match_id'])) { + return; + } + app.db.tournaments.findOne({ key: msg.tournament_key }, async (err, tournament) => { + if (err) { + return ws.respond(msg, err); + } + + update_queue.instance().execute(process_match,app, msg, tournament).then(res => { + ws.respond(msg); + }).catch(err => { + ws.respond(msg, err); + }); + }); + +} + + +function process_match(app, msg, tournament) { + return new Promise((resolve, reject) => { + const match_utils = require('./match_utils'); + app.db.matches.findOne({ tournament_key: msg.tournament_key, _id: msg.match_id }, async (err, match) => { + if (err) { + reject(err); + return; + } + if (match != null) { + match.setup.court_id = msg.court_id; + match.setup.now_on_court = true; + match_utils.call_match(app, tournament, match, undefined, (err, updated_match) => { + if (err) { + reject(err); + } else { + resolve(updated_match); + } + }); + } else { + reject(new Error("Match cannot be fetched from DB 222 " + msg.match_id)); + } + }); + }); +} + +function handle_match_edit(app, ws, msg) { + const match_utils = require('./match_utils'); + + if (!_require_msg(ws, msg, ['tournament_key', 'id', 'match', 'old_court'])) { + return; + } + const tournament_key = msg.tournament_key; + const setup = msg.match.setup; + + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return ws.respond(msg, err); + } + + if(setup.now_on_court && !setup.called_timestamp) { + match_utils.call_match(app, tournament, msg.match, msg.old_court, (err, match) => { + ws.respond(msg, err); + return; + }); + } + else if(setup.now_on_court && setup.court_id) { + match_utils.switch_court(app, tournament, msg.match, msg.old_court, (err, match) => { + ws.respond(msg, err); + return; + }); + } + else if (!setup.now_on_court && setup.called_timestamp) { + match_utils.uncall_match(app, tournament, msg.match, msg.old_court, (err) => { + ws.respond(msg, err); + return; + }); + + } else { + app.db.matches.findOne({_id: msg.id, tournament_key}, function(old_err, old_match) { + if (old_err) { + ws.respond(msg, old_err); + return; + } + if (!old_match) { + ws.respond(msg, new Error('Cannot find match ' + msg.id + ' of tournament ' + tournament_key + ' in database')); + return; + } + + const dependent_releases = _collect_dependent_official_releases(setup); + const official_sync_meta = _build_match_edit_official_sync_meta(old_match.setup || {}, setup || {}); + const update_set = { setup }; + if (official_sync_meta.has_official_change) { + update_set.btp_needsync = true; + } + app.db.matches.update({_id: msg.id, tournament_key}, {$set: update_set}, {returnUpdatedDocs: true}, function(err, numAffected, changed_match) { + if (err) { + ws.respond(msg, err); + return; + } + if (numAffected !== 1) { + ws.respond(msg, new Error('Cannot find match ' + msg.id + ' of tournament ' + tournament_key + ' in database')); + return; + } + if (changed_match._id !== msg.id) { + const errmsg = 'Match ' + changed_match._id + ' changed by accident, intended to change ' + msg.id + ' (old nedb version?)'; + serror.silent(errmsg); + ws.respond(msg, new Error(errmsg)); + return; + } + + _apply_match_edit_official_state_changes(app, tournament_key, old_match.setup || {}, changed_match.setup || {}, function(official_err) { + if (official_err) { + ws.respond(msg, official_err); + return; + } + _apply_wait_releases(app, tournament_key, dependent_releases, Date.now() / 10, function(release_err) { + if (release_err) { + ws.respond(msg, release_err); + return; + } + notify_change(app, tournament_key, 'match_edit', {match__id: msg.id, match: changed_match}); + if (msg.btp_update) { + btp_manager.update_score(app, changed_match); + } + app.db.umpires.find({ tournament_key }, function (umpire_err, all_umpires) { + if (umpire_err) { + ws.respond(msg, umpire_err); + return; + } + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + ws.respond(msg, err); + }); + }); + }); + }); + }); + } + }); +} + +function _roles_by_official_id_from_setup(setup) { + const map = new Map(); + ['umpire', 'service_judge'].forEach((role) => { + const official = setup && setup[role]; + if (official && official._id) { + if (!map.has(official._id)) { + map.set(official._id, { official, roles: new Set() }); + } + map.get(official._id).roles.add(role); + } + }); + return map; +} + +function _build_match_edit_official_sync_meta(old_setup, new_setup) { + const result = { + has_official_change: false + }; + ['umpire', 'service_judge'].forEach((role) => { + const suppressed_key = role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'; + const old_official = old_setup && old_setup[role]; + const new_official = new_setup && new_setup[role]; + const old_btp_id = old_official && old_official.btp_id != null ? String(old_official.btp_id) : null; + const new_btp_id = new_official && new_official.btp_id != null ? String(new_official.btp_id) : null; + if (old_btp_id !== new_btp_id) { + result.has_official_change = true; + if (old_official && old_official.btp_id != null) { + new_setup[suppressed_key] = old_official.btp_id; + } + } + if (new_official && new_official.btp_id != null) { + delete new_setup[suppressed_key]; + } + }); + return result; +} + +function _collect_dependent_official_releases(setup) { + const releases = []; + if (!setup.umpire && setup.service_judge) { + const dependent = setup.service_judge; + if (dependent && dependent.btp_id != null) { + setup.suppressed_service_judge_btp_id = dependent.btp_id; + } + delete setup.service_judge; + if (dependent && dependent._id) { + releases.push({ + official_id: dependent._id, + wait_field: 'service_judge_wait', + target_position: 'front' + }); + } + } + return releases; +} + +function _remove_official_from_setup(setup, role) { + const current_btp_id = setup[role] && setup[role].btp_id != null ? setup[role].btp_id : null; + delete setup[role]; + if (current_btp_id != null) { + setup[role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'] = current_btp_id; + } + return _collect_dependent_official_releases(setup); +} + +function _official_wait_set_obj(wait_field, ts) { + const setObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + }; + setObj[wait_field] = ts; + return setObj; +} + +function _official_list_target_ts(to_list, base_ts, tournament) { + return base_ts; +} + +function _official_list_target_field(to_list) { + if (to_list === 'umpire_pause') { + return 'umpire_manual_pause'; + } + if (to_list === 'service_judge_pause') { + return 'service_judge_manual_pause'; + } + return to_list; +} + +function _apply_wait_releases(app, tournament_key, releases, start_ts, cb) { + if (!releases.length) { + cb(null, []); + return; + } + let index = 0; + const updated_ids = []; + const next = () => { + if (index >= releases.length) { + cb(null, updated_ids); + return; + } + const release = releases[index++]; + updated_ids.push(release.official_id); + const release_ts = release.target_position === 'front' + ? index + : (start_ts + index - 1); + app.db.umpires.update( + { _id: release.official_id, tournament_key }, + { $set: _official_wait_set_obj(release.wait_field, release_ts) }, + {}, + function (err) { + if (err) { + cb(err); + return; + } + next(); + } + ); + }; + next(); +} + +function _preferred_wait_field_from_roles(official_doc, old_roles) { + if (old_roles.has('service_judge') && !old_roles.has('umpire')) { + return 'service_judge_wait'; + } + if (old_roles.has('umpire') && !old_roles.has('service_judge')) { + return 'umpire_wait'; + } + if (official_doc && official_doc.is_umpire && !official_doc.is_service_judge) { + return 'umpire_wait'; + } + if (official_doc && official_doc.is_service_judge && !official_doc.is_umpire) { + return 'service_judge_wait'; + } + return 'umpire_wait'; +} + +function _apply_match_edit_official_state_changes(app, tournament_key, old_setup, new_setup, cb) { + const old_roles_by_id = _roles_by_official_id_from_setup(old_setup); + const new_roles_by_id = _roles_by_official_id_from_setup(new_setup); + const affected_ids = [...new Set([...old_roles_by_id.keys(), ...new_roles_by_id.keys()])]; + if (!affected_ids.length) { + cb(); + return; + } + + app.db.umpires.find({ tournament_key, _id: { $in: affected_ids } }, function (err, officials) { + if (err) { + cb(err); + return; + } + const official_by_id = new Map((officials || []).map((official) => [official._id, official])); + const updates = affected_ids.map((official_id) => { + const official_doc = official_by_id.get(official_id); + if (!official_doc) { + return null; + } + const old_roles = old_roles_by_id.get(official_id)?.roles || new Set(); + const new_roles = new_roles_by_id.get(official_id)?.roles || new Set(); + const same_roles = old_roles.size === new_roles.size && [...old_roles].every((role) => new_roles.has(role)); + if (same_roles) { + return null; + } + const setObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: new_roles.has('service_judge'), + is_planed_as_umpire: new_roles.has('umpire') + }; + if (new_roles.size === 0) { + setObj[_preferred_wait_field_from_roles(official_doc, old_roles)] = Date.now() / 10; + } + return { official_id, setObj }; + }).filter(Boolean); + + if (!updates.length) { + cb(); + return; + } + + let index = 0; + const next = () => { + if (index >= updates.length) { + cb(); + return; + } + const update = updates[index++]; + app.db.umpires.update( + { _id: update.official_id, tournament_key }, + { $set: update.setObj }, + {}, + function (update_err) { + if (update_err) { + cb(update_err); + return; + } + next(); + } + ); + }; + next(); + }); +} + + + + +function handle_match_preparation_call(app, ws, msg) { + + const match_utils = require('./match_utils'); + + if (!_require_msg(ws, msg, ['tournament_key', 'match', 'location_id'])) { + return; + } + if (match_utils.match_completly_initialized(msg.match.setup) == false) { + return ws.respond("Match cannot be called one or more Teams are not set."); + } + + const tournament_key = msg.tournament_key; + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return ws.respond(err); + } + + match_utils.call_match_in_preparation(app, tournament, msg.match, msg.location_id, (err) => { + ws.respond(msg, err); + return; + }, { force: true }); + }); +} + +function handle_match_player_check_in (app, ws, msg) { + const match_utils = require('./match_utils'); + + if (!_require_msg(ws, msg, ['tournament_key', 'player_id', 'match_id', 'checked_in'])) { + return; + } + + update_queue.instance().execute(update_queue.named('handle_match_player_check_in', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: msg.tournament_key }, async (err, tournament) => { + if (err) { + return reject(err); + } + + app.db.matches.findOne({tournament_key: msg.tournament_key, _id: msg.match_id}, async (err, match) => { + if (err) { + return reject(err); + } + if (!match || !match.setup) { + return reject(new Error('Match not found')); + } + + let player_found = false; + for (const team of match.setup.teams) { + for (const player of team.players) { + if (player.btp_id == msg.player_id) { + player.checked_in = msg.checked_in; + player_found = true; + } + } + } + + if (!player_found) { + return reject(new Error('Player not found in match')); + } + + match_utils.match_update(app, match, undefined, (err) => { + if (err) { + return reject(err); + } + console.log('[bts] auto_call_trace:player_check_in_updated', { + ts: Date.now(), + tournament_key: msg.tournament_key, + match_id: msg.match_id, + player_id: msg.player_id, + checked_in: !!msg.checked_in, + }); + trigger_auto_call_after_readiness_change(app, msg.tournament_key); + resolve(); + }); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => ws.respond(msg, err)); +} + +function trigger_auto_call_after_readiness_change(app, tournament_key) { + const match_utils = require('./match_utils'); + console.log('[bts] auto_call_trace:readiness_trigger_start', { + ts: Date.now(), + tournament_key, + }); + match_utils.queue_auto_execute_preparation_selections(app, tournament_key, (selectionErr) => { + if (selectionErr) { + console.warn('[bts] failed to auto select preparation matches after readiness change', selectionErr && (selectionErr.stack || selectionErr.message || String(selectionErr))); + return; + } + console.log('[bts] auto_call_trace:readiness_trigger_after_preparation_selection', { + ts: Date.now(), + tournament_key, + }); + match_utils.auto_call_matches_on_free_courts(app, tournament_key, (callErr) => { + if (callErr) { + console.warn('[bts] failed to auto call matches on free courts after readiness change', callErr && (callErr.stack || callErr.message || String(callErr))); + return; + } + console.log('[bts] auto_call_trace:readiness_trigger_after_auto_call', { + ts: Date.now(), + tournament_key, + }); + }); + }); +} + +function handle_match_participant_check_in(app, ws, msg) { + const match_utils = require('./match_utils'); + + if (!_require_msg(ws, msg, ['tournament_key', 'match_id', 'role', 'checked_in'])) { + return; + } + + update_queue.instance().execute(update_queue.named('handle_match_participant_check_in', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: msg.tournament_key }, (tournament_err, tournament) => { + if (tournament_err) { + return reject(tournament_err); + } + app.db.matches.findOne({ tournament_key: msg.tournament_key, _id: msg.match_id }, async (err, match) => { + if (err) { + return reject(err); + } + if (!match || !match.setup) { + return reject(new Error('Match not found')); + } + + let participant_found = false; + const checked_in = !!msg.checked_in; + + if (msg.role === 'umpire' || msg.role === 'service_judge') { + if (tournament?.btp_settings?.check_in_per_match === false) { + return resolve(); + } + const participant = match.setup[msg.role]; + if (participant && participant.btp_id == msg.participant_id) { + participant.checked_in = checked_in; + participant_found = true; + } + } else if (msg.role === 'tabletoperator' && Array.isArray(match.setup.tabletoperators)) { + match.setup.tabletoperators.forEach((participant) => { + if (participant.btp_id == msg.participant_id) { + participant.checked_in = checked_in; + participant_found = true; + } + }); + } + + if (!participant_found) { + return reject(new Error('Participant not found in match')); + } + + match_utils.match_update(app, match, undefined, (update_err) => { + if (update_err) { + return reject(update_err); + } + console.log('[bts] auto_call_trace:participant_check_in_updated', { + ts: Date.now(), + tournament_key: msg.tournament_key, + match_id: msg.match_id, + role: msg.role, + participant_id: msg.participant_id, + checked_in, + }); + if ((msg.role === 'umpire' || msg.role === 'service_judge') && tournament?.btp_settings?.check_in_per_match !== false) { + const participant = match.setup[msg.role]; + if (!participant) { + trigger_auto_call_after_readiness_change(app, msg.tournament_key); + return resolve(); + } + const official_query = participant._id + ? { tournament_key: msg.tournament_key, _id: participant._id } + : { tournament_key: msg.tournament_key, btp_id: msg.participant_id }; + return app.db.umpires.update( + official_query, + { $set: { checked_in } }, + { returnUpdatedDocs: true }, + (official_err, numAffected, updated_official) => { + if (official_err) { + return reject(official_err); + } + if (numAffected > 0 && updated_official) { + notify_change(app, msg.tournament_key, 'umpire_updated', updated_official); + } + trigger_auto_call_after_readiness_change(app, msg.tournament_key); + resolve(); + } + ); + } + trigger_auto_call_after_readiness_change(app, msg.tournament_key); + resolve(); + }); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => ws.respond(msg, err)); +} + + +function handle_begin_to_play_call(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'begin_to_play_call', {setup}); + + ws.respond(msg); +} + +function handle_announce_match_manually(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'match'])) { + return; + } + notify_change(app, msg.tournament_key, 'match_called_on_court', msg.match); + ws.respond(msg); +} + + +function handle_free_announce(app, ws, msg) { + if (!_require_msg(ws, msg, ['text'])) { + return; + } + const tournament_key = msg.tournament_key; + const text = msg.text; + + notify_change(app, tournament_key, 'free_announce', {text}); + + ws.respond(msg); +} + +function handle_emergency_announce(app, ws, msg) { + + if (!_require_msg(ws, msg, ['tournament_key', 'enable'])) { + return; + } + const tournament_key = msg.tournament_key; + const enable = msg.enable; + + notify_change(app, tournament_key, 'emergency_announce', enable); + + ws.respond(msg); +} + +function handle_second_call_tabletoperator(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_call_tabletoperator', {setup}); + + ws.respond(msg); +} + +function handle_second_preparation_call_tabletoperator(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_preparation_call_tabletoperator', {setup}); + + ws.respond(msg); +} + +function handle_second_call_umpire(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_call_umpire', { setup }); + + ws.respond(msg); +} + +function handle_second_preparation_call_umpire(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_preparation_call_umpire', { setup }); + + ws.respond(msg); +} + +function handle_second_call_servicejudge(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_call_servicejudge', { setup }); + + ws.respond(msg); +} + +function handle_second_preparation_call_servicejudge(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_preparation_call_servicejudge', { setup }); + + ws.respond(msg); +} + + +function handle_second_call_team_one(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_call_team_one', {setup}); + + ws.respond(msg); +} + + +function handle_second_preparation_call_team_one(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_preparation_call_team_one', {setup}); + + ws.respond(msg); +} + +function handle_official_list_move(app, ws, msg) { + const match_utils = require('./match_utils'); + if (!_require_msg(ws, msg, [ + 'tournament_key', + 'official_id', + 'from_list', + 'to_list' + ])) { + return; + } + + const { + tournament_key, + official_id, + prev_btp_id, + next_btp_id, + prev_official_id, + next_official_id, + ordered_official_ids, + from_list, + to_list + } = msg; + + app.db.tournaments.findOne({ key: tournament_key }, function (tournament_err, tournament) { + if (tournament_err) return cerror.ws(ws, tournament_err); + if (!tournament) return cerror.ws(ws, new Error('tournament not found')); + + if (Array.isArray(ordered_official_ids) && ordered_official_ids.length > 0) { + const unique_ordered_ids = [...new Set(ordered_official_ids.filter(Boolean))]; + if (!unique_ordered_ids.includes(official_id)) { + unique_ordered_ids.push(official_id); + } + return app.db.umpires.find({ + tournament_key, + _id: { $in: unique_ordered_ids } + }, function(err, docs) { + if (err) return cerror.ws(ws, err); + const currentUmpire = docs.find((u) => u._id === official_id); + if (!currentUmpire) { + return cerror.ws(ws, new Error('current umpire not found')); + } + + const now = Date.now(); + const updates = unique_ordered_ids.map((id, index) => { + const setObj = {}; + if (id === official_id) { + setObj[from_list] = null; + setObj['inactive_list'] = null; + setObj['service_judge_pause'] = null; + setObj['umpire_pause'] = null; + setObj['service_judge_manual_pause'] = null; + setObj['umpire_manual_pause'] = null; + setObj['service_judge_wait'] = null; + setObj['umpire_wait'] = null; + setObj['service_judge_on_court'] = null; + setObj['umpire_on_court'] = null; + setObj['is_planed_as_service_judge'] = false; + setObj['is_planed_as_umpire'] = false; + } + setObj[_official_list_target_field(to_list)] = _official_list_target_ts(to_list, now + index, tournament); + const updated_official = { ...(docs.find((u) => u._id === id) || {}), ...setObj }; + setObj.checked_in = match_utils.get_effective_technical_official_checked_in(updated_official, tournament); + return { _id: id, setObj }; + }); + + return async.eachSeries(updates, function(entry, next) { + app.db.umpires.update( + { _id: entry._id, tournament_key }, + { $set: entry.setObj }, + {}, + next + ); + }, function(err2) { + if (err2) return cerror.ws(ws, err2); + app.db.umpires.find( + { tournament_key, _id: { $in: unique_ordered_ids } }, + function(err3, updatedOfficials) { + if (err3) return cerror.ws(ws, err3); + app.db.umpires.find({ tournament_key }, function(err4, all_umpires) { + if (err4) return cerror.ws(ws, err4); + updatedOfficials.forEach((updated) => { + notify_change(app, tournament_key, 'umpire_updated', updated); + }); + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + notify_change(app, tournament_key, 'official_list_move', { + official_id, + from_list, + to_list, + new_ts: _official_list_target_ts(to_list, now + unique_ordered_ids.indexOf(official_id), tournament), + }); + ws.respond(msg); + }); + } + ); + }); + }); + } + + // btp_id sicher normalisieren + const prevId = (prev_btp_id == null) ? null : Number(prev_btp_id); + const nextId = (next_btp_id == null) ? null : Number(next_btp_id); + + const neighborOfficialIds = []; + if (prev_official_id) neighborOfficialIds.push(prev_official_id); + if (next_official_id) neighborOfficialIds.push(next_official_id); + + const neighborBtpIds = []; + if (Number.isFinite(prevId)) neighborBtpIds.push(prevId); + if (Number.isFinite(nextId)) neighborBtpIds.push(nextId); + + // Query: current über _id, prev/next primär über _id, fallback über btp_id + const query = { + tournament_key, + $or: [{ _id: official_id }] + }; + if (neighborOfficialIds.length > 0) { + query.$or.push({ _id: { $in: neighborOfficialIds } }); + } + if (neighborBtpIds.length > 0) { + query.$or.push({ btp_id: { $in: neighborBtpIds } }); + } + + app.db.umpires.find(query, function (err, docs) { + if (err) return cerror.ws(ws, err); + + let currentUmpire = null; + let prevUmpire = null; + let nextUmpire = null; + + for (const u of docs) { + if (u._id === official_id) { + currentUmpire = u; + continue; + } + if (prev_official_id && u._id === prev_official_id) { + prevUmpire = u; + continue; + } + if (next_official_id && u._id === next_official_id) { + nextUmpire = u; + continue; + } + if (Number.isFinite(prevId) && Number(u.btp_id) === prevId) { + prevUmpire = u; + continue; + } + if (Number.isFinite(nextId) && Number(u.btp_id) === nextId) { + nextUmpire = u; + } + } + + if (!currentUmpire) { + return cerror.ws(ws, new Error('current umpire not found')); + } + + // --- Timestamp für to_list berechnen gemäß deiner Regeln (robust gegen null) --- + const now = Date.now(); + + const prevTS = (prevUmpire && prevUmpire[to_list] != null) ? Number(prevUmpire[to_list]) : null; + const nextTS = (nextUmpire && nextUmpire[to_list] != null) ? Number(nextUmpire[to_list]) : null; + + const prevOk = (prevTS != null) && Number.isFinite(prevTS); + const nextOk = (nextTS != null) && Number.isFinite(nextTS); + + let newTS; + + // Ende der Liste: wenn es keinen Nachfolger gibt -> aktueller Timestamp + if (!nextUmpire || !nextOk) { + newTS = now; + + // Anfang der Liste: kein Vorgänger, aber Nachfolger -> zwischen 0 und next + } else if (!prevUmpire || !prevOk) { + newTS = nextTS / 2; + + // Zwischen zwei Elementen -> Mittelwert + } else { + newTS = (prevTS + nextTS) / 2; + } + newTS = _official_list_target_ts(to_list, newTS, tournament); + + // --- Update vorbereiten --- + // Spezifikation: + // - currentUmpire[from_list] = null + // - currentUmpire[to_list] = newTS + const setObj = {}; + + setObj[from_list] = null; + setObj['inactive_list'] = null; + setObj['service_judge_pause'] = null; + setObj['umpire_pause'] = null; + setObj['service_judge_manual_pause'] = null; + setObj['umpire_manual_pause'] = null; + setObj['service_judge_wait'] = null; + setObj['umpire_wait'] = null; + setObj['service_judge_on_court'] = null; + setObj['umpire_on_court'] = null; + setObj['is_planed_as_service_judge'] = false; + setObj['is_planed_as_umpire'] = false; + setObj[_official_list_target_field(to_list)] = newTS; + setObj.checked_in = match_utils.get_effective_technical_official_checked_in({ ...currentUmpire, ...setObj }, tournament); + + app.db.umpires.update( + { _id: currentUmpire._id, tournament_key }, + { $set: setObj }, + {}, + function (err2) { + if (err2) return cerror.ws(ws, err2); + + // Optional: aktualisiertes Objekt laden (für Broadcast/Clients) + app.db.umpires.findOne( + { _id: currentUmpire._id, tournament_key }, + function (err3, updated) { + if (err3) return cerror.ws(ws, err3); + + notify_change(app, tournament_key, 'official_list_move', { + official_id: currentUmpire._id, + from_list, + to_list, + new_ts: newTS, + }); + notify_change(app, tournament_key, 'umpire_updated', updated); + + ws.respond(msg); + } + ); + } + ); + }); + }); +} + +function handle_official_edit(app, ws, msg) { + // Pflichtfelder prüfen + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'field', 'value'])) { + return; + } + + const { tournament_key, official_id, field, value } = msg; + + // Nur diese Felder dürfen vom Client geändert werden + if (field !== 'is_umpire' && field !== 'is_service_judge') { + return ws.respond( + msg, + new Error('Field not allowed for official_edit: ' + field) + ); + } + + // Checkbox-Wert normalisieren + const newVal = !!value; + + // Offiziellen suchen + app.db.umpires.findOne( + { _id: official_id, tournament_key }, + function (err, umpire) { + if (err) { + return ws.respond(msg, err); + } + + if (!umpire) { + return ws.respond( + msg, + new Error( + 'Cannot find official ' + + official_id + + ' of tournament ' + + tournament_key + + ' in database' + ) + ); + } + + // Update vorbereiten + const setObj = {}; + setObj[field] = newVal; + setObj.updated_at = Date.now(); // optional + + // DB-Update + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: setObj }, + {}, + function (err2) { + if (err2) { + return ws.respond(msg, err2); + } + + // Aktualisiertes Dokument laden (für Broadcast) + app.db.umpires.findOne( + { _id: official_id, tournament_key }, + function (err3, updated) { + if (err3) { + return ws.respond(msg, err3); + } + + // Broadcast an alle Clients + notify_change(app, tournament_key, 'official_edit', { + official_id, + field, + value: newVal + }); + + ws.respond(msg); + } + ); + } + ); + } + ); +} + +function handle_official_roles_edit(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'is_umpire', 'is_service_judge'])) { + return; + } + + const { tournament_key, official_id } = msg; + const setObj = { + is_umpire: !!msg.is_umpire, + is_service_judge: !!msg.is_service_judge, + updated_at: Date.now() + }; + + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err, umpire) { + if (err) { + return ws.respond(msg, err); + } + if (!umpire) { + return ws.respond( + msg, + new Error( + 'Cannot find official ' + + official_id + + ' of tournament ' + + tournament_key + + ' in database' + ) + ); + } + + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: setObj }, + {}, + function (err2) { + if (err2) { + return ws.respond(msg, err2); + } + + app.db.umpires.findOne( + { _id: official_id, tournament_key }, + function (err3, updated) { + if (err3) { + return ws.respond(msg, err3); + } + + notify_change(app, tournament_key, 'umpire_updated', updated); + ws.respond(msg); + } + ); + } + ); + }); +} + +function _assign_next_umpire_to_match(app, tournament_key, match_id, options = {}) { + const skip_btp_push = options && options.skip_btp_push === true; + return new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, function (tournament_err, tournament) { + if (tournament_err) return reject(tournament_err); + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err, match) { + if (err) return reject(err); + if (!match) { + return reject( + new Error('Cannot find match ' + match_id + ' of tournament ' + tournament_key + ' in database') + ); + } + + if (match.setup?.umpire) { + return reject( + new Error('Match already has assigned umpire') + ); + } + + const setup = match.setup; + if (setup.court_id) { + return app.db.courts.findOne({ tournament_key, _id: setup.court_id }, function(courtErr, court) { + if (courtErr) return reject(courtErr); + if (court && court.has_umpire === false) { + return reject(new Error('Court has no space for an umpire')); + } + return continue_assign(); + }); + } + + return continue_assign(); + + function continue_assign() { + + app.db.umpires + .find({ tournament_key, umpire_wait: { $ne: null } }) + .sort({ umpire_wait: 1 }) + .limit(1) + .exec(function (err2, umps) { + if (err2) return reject(err2); + if (!umps || umps.length === 0) { + return reject(new Error('No umpire available')); + } + + const umpire = umps[0]; + + app.db.umpires.update( + { _id: umpire._id, tournament_key, umpire_wait: { $ne: null } }, + { $set: { umpire_wait: null, + service_judge_wait: null, + is_planed_as_umpire: true, + is_planed_as_service_judge: false } }, + {}, + function (err3, affected1) { + if (err3) return reject(err3); + if (affected1 === 0) { + return reject(new Error('Umpire was already taken by another assignment')); + } + + setup.umpire = _pack_official_for_match(umpire, options.tournament || tournament || null); + + app.db.matches.update( + { _id: match_id, tournament_key, 'setup.umpire': { $exists: false } }, + { $set: { setup, btp_needsync: true } }, + {}, + function (err4, affectedMatch) { + if (err4 || affectedMatch === 0) { + app.db.umpires.update( + { _id: umpire._id, tournament_key }, + { $set: { umpire_wait: umpire.is_umpire ? Date.now()/10 : null, + service_judge_wait: umpire.is_service_judge ? Date.now()/10 : null, + is_planed_as_umpire: false, + is_planed_as_service_judge: false + } } + ); + return reject(err4 || new Error('Match changed during official assignment')); + } + + app.db.matches.findOne( + { _id: match_id, tournament_key }, + function (err5, updatedMatch) { + if (err5) return reject(err5); + + notify_change(app, tournament_key, 'match_edit', {match__id: match_id, match: updatedMatch}); + if (!skip_btp_push) { + btp_manager.update_score(app, updatedMatch); + } + + app.db.umpires.find( + { tournament_key, _id: umpire._id }, + function (err6, updatedOfficials) { + if (err6) { + return reject(err6); + } + if (updatedOfficials) { + for (const u of updatedOfficials) { + notify_change(app, tournament_key, 'umpire_updated', u); + } + } + app.db.umpires.find({ tournament_key }, function (err7, all_umpires) { + if (err7) { + return reject(err7); + } + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + resolve(); + }); + } + ); + } + ); + } + ); + } + ); + }); + } + }); + }); + }); +} + +function assign_next_umpire_to_match(app, tournament_key, match_id) { + return update_queue.instance().execute(update_queue.named('handle_add_officials_to_match', () => _assign_next_umpire_to_match(app, tournament_key, match_id))); +} + +function handle_add_officials_to_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'match_id'])) { + return; + } + + const { tournament_key, match_id } = msg; + assign_next_umpire_to_match(app, tournament_key, match_id) + .then(() => ws.respond(msg)) + .catch((err) => ws.respond(msg, err)); +} + +function _assign_next_service_judge_to_match(app, tournament_key, match_id, options = {}) { + const skip_btp_push = options && options.skip_btp_push === true; + return new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, function (tournament_err, tournament) { + if (tournament_err) return reject(tournament_err); + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err, match) { + if (err) return reject(err); + if (!match) { + return reject( + new Error('Cannot find match ' + match_id + ' of tournament ' + tournament_key + ' in database') + ); + } + + if (!match.setup?.umpire) { + return reject(new Error('Match has no assigned umpire')); + } + if (match.setup?.service_judge) { + return reject(new Error('Match already has assigned service judge')); + } + + const setup = match.setup; + if (setup.court_id) { + return app.db.courts.findOne({ tournament_key, _id: setup.court_id }, function(courtErr, court) { + if (courtErr) return reject(courtErr); + if (court && court.has_service_judge === false) { + return reject(new Error('Court has no space for a service judge')); + } + return continue_assign(); + }); + } + + return continue_assign(); + + function continue_assign() { + + app.db.umpires + .find({ tournament_key, service_judge_wait: { $ne: null } }) + .sort({ service_judge_wait: 1 }) + .limit(1) + .exec(function (err2, sjs) { + if (err2) return reject(err2); + if (!sjs || sjs.length === 0) { + return reject(new Error('No service judge available')); + } + + const service_judge = sjs[0]; + + app.db.umpires.update( + { _id: service_judge._id, tournament_key, service_judge_wait: { $ne: null } }, + { $set: { service_judge_wait: null, umpire_wait: null, is_planed_as_service_judge: true, is_planed_as_umpire: false } }, + {}, + function (err3, affected) { + if (err3) return reject(err3); + if (affected === 0) { + return reject(new Error('Service judge was already taken')); + } + + setup.service_judge = _pack_official_for_match(service_judge, options.tournament || tournament || null); + + app.db.matches.update( + { _id: match_id, tournament_key, 'setup.umpire': { $exists: true }, 'setup.service_judge': { $exists: false } }, + { $set: { setup, btp_needsync: true } }, + {}, + function (err4, affectedMatch) { + if (err4 || affectedMatch === 0) { + app.db.umpires.update( + { _id: service_judge._id, tournament_key }, + { $set: { + service_judge_wait: service_judge.is_service_judge ? Date.now() / 10 : null, + umpire_wait: service_judge.is_umpire ? Date.now() / 10 : null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + } } + ); + return reject(err4 || new Error('Match changed during service judge assignment')); + } + + app.db.matches.findOne( + { _id: match_id, tournament_key }, + function (err5, updatedMatch) { + if (err5) return reject(err5); + + notify_change(app, tournament_key, 'match_edit', { match__id: match_id, match: updatedMatch }); + if (!skip_btp_push) { + btp_manager.update_score(app, updatedMatch); + } + + app.db.umpires.findOne( + { tournament_key, _id: service_judge._id }, + function (err6, updatedOfficial) { + if (err6) { + return reject(err6); + } + if (updatedOfficial) { + notify_change(app, tournament_key, 'umpire_updated', updatedOfficial); + } + app.db.umpires.find({ tournament_key }, function (err7, all_umpires) { + if (err7) { + return reject(err7); + } + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + resolve(); + }); + } + ); + } + ); + } + ); + } + ); + }); + } + }); + }); + }); +} + +function assign_next_service_judge_to_match(app, tournament_key, match_id) { + return update_queue.instance().execute(update_queue.named('handle_add_service_judge_to_match', () => _assign_next_service_judge_to_match(app, tournament_key, match_id))); +} + +function handle_add_service_judge_to_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'match_id'])) { + return; + } + + const { tournament_key, match_id } = msg; + assign_next_service_judge_to_match(app, tournament_key, match_id) + .then(() => ws.respond(msg)) + .catch((err) => ws.respond(msg, err)); +} + +function _pack_official_for_match(u, tournament = null) { + const match_utils = require('./match_utils'); + return { + _id: u._id, + btp_id: u.btp_id, + name: u.name, + firstname: u.firstname, + surname: u.surname, + country: u.country, + is_umpire: !!u.is_umpire, + is_service_judge: !!u.is_service_judge, + umpire_wait: u.umpire_wait ?? null, + service_judge_wait: u.service_judge_wait ?? null, + checked_in: match_utils.get_effective_technical_official_checked_in(u, tournament) + }; +} + +function handle_assign_official_to_preparation_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'match_id', 'role'])) { + return; + } + + const { tournament_key, official_id, match_id, role, source_match_id, source_type, source_role } = msg; + if (role !== 'umpire' && role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid role for assign_official_to_preparation_match: ' + role)); + } + if (source_type != null && source_type !== 'preparation' && source_type !== 'assigned') { + return cerror.ws(ws, new Error('Invalid source_type for assign_official_to_preparation_match: ' + source_type)); + } + if (source_role != null && source_role !== 'umpire' && source_role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid source_role for assign_official_to_preparation_match: ' + source_role)); + } + + const role_flag = role === 'umpire' ? 'is_planed_as_umpire' : 'is_planed_as_service_judge'; + + update_queue.instance().execute(update_queue.named('handle_assign_official_to_preparation_match', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, function (tournament_err, tournament) { + if (tournament_err) return reject(tournament_err); + app.db.matches.find({ tournament_key, _id: { $in: [...new Set([match_id, source_match_id].filter(Boolean))] } }, function (err, matches) { + if (err) return reject(err); + const match = matches.find((m) => m._id === match_id); + if (!match) return reject(new Error('Cannot find match ' + match_id)); + if ((match.setup || {}).state !== 'preparation') { + return reject(new Error('Match is not in preparation')); + } + const source_match = source_match_id ? matches.find((m) => m._id === source_match_id) : null; + if (source_match_id && !source_match) { + return reject(new Error('Cannot find source match ' + source_match_id)); + } + const same_match_move = !!source_match && source_match._id === match_id; + if (match.setup && match.setup[role] && (!same_match_move || source_role !== role || match.setup[role]._id !== official_id)) { + return reject(new Error('Match already has assigned ' + role)); + } + if (source_match) { + const source_setup = source_match.setup || {}; + const source_official = source_setup[source_role]; + if (!source_official || source_official._id !== official_id) { + return reject(new Error('Official is not assigned to the source match/role')); + } + } + + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err2, official) { + if (err2) return reject(err2); + if (!official) return reject(new Error('Cannot find official ' + official_id)); + + const target_setup = structuredClone(match.setup || {}); + const source_setup = source_match ? structuredClone(source_match.setup || {}) : null; + if (source_setup && source_role) { + const current_btp_id = source_setup[source_role] && source_setup[source_role].btp_id != null ? source_setup[source_role].btp_id : null; + delete source_setup[source_role]; + if (current_btp_id != null) { + source_setup[source_role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'] = current_btp_id; + } + if (same_match_move) { + delete target_setup[source_role]; + if (current_btp_id != null) { + target_setup[source_role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'] = current_btp_id; + } + } + } + target_setup[role] = _pack_official_for_match(official, tournament || null); + delete target_setup[role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id']; + + const officialSetObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + }; + officialSetObj[role_flag] = true; + + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: officialSetObj }, + {}, + function (err3) { + if (err3) return reject(err3); + + const finish = () => { + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err6, updatedOfficial) { + if (err6) return reject(err6); + const match_ids = [...new Set([match_id, source_match_id].filter(Boolean))]; + app.db.matches.find({ tournament_key, _id: { $in: match_ids } }, function (err7, updatedMatches) { + if (err7) return reject(err7); + updatedMatches.forEach((updatedMatch) => { + notify_change(app, tournament_key, 'match_edit', { match__id: updatedMatch._id, match: updatedMatch }); + btp_manager.update_score(app, updatedMatch); + }); + notify_change(app, tournament_key, 'umpire_updated', updatedOfficial); + resolve(); + }); + }); + }; + + if (same_match_move) { + const same_guard = { _id: match_id, tournament_key, [`setup.${source_role}._id`]: official_id }; + if (source_role !== role) { + same_guard[`setup.${role}`] = { $exists: false }; + } + app.db.matches.update( + same_guard, + { $set: { setup: target_setup, btp_needsync: true } }, + {}, + function (err4, affected) { + if (err4) return reject(err4); + if (!affected) return reject(new Error('Match changed during official reassignment')); + finish(); + } + ); + return; + } + + const update_source = (cb) => { + if (!source_match) return cb(); + const source_guard = { _id: source_match_id, tournament_key, [`setup.${source_role}._id`]: official_id }; + app.db.matches.update( + source_guard, + { $set: { setup: source_setup, btp_needsync: true } }, + {}, + function (err4, affected) { + if (err4) return cb(err4); + if (!affected) return cb(new Error('Source match changed during official move')); + cb(); + } + ); + }; + + update_source(function (err4) { + if (err4) return reject(err4); + const guard = { _id: match_id, tournament_key }; + guard[`setup.${role}`] = { $exists: false }; + app.db.matches.update( + guard, + { $set: { setup: target_setup, btp_needsync: true } }, + {}, + function (err5, affected) { + if (err5) return reject(err5); + if (!affected) return reject(new Error('Match changed during official assignment')); + finish(); + } + ); + }); + } + ); + }); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => cerror.ws(ws, err)); +} + +function handle_assign_official_to_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'match_id', 'role'])) { + return; + } + + const { tournament_key, official_id, match_id, role, source_match_id, source_type, source_role } = msg; + if (role !== 'umpire' && role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid role for assign_official_to_match: ' + role)); + } + if (source_type != null && source_type !== 'preparation' && source_type !== 'assigned') { + return cerror.ws(ws, new Error('Invalid source_type for assign_official_to_match: ' + source_type)); + } + if (source_role != null && source_role !== 'umpire' && source_role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid source_role for assign_official_to_match: ' + source_role)); + } + + const role_flag = role === 'umpire' ? 'is_planed_as_umpire' : 'is_planed_as_service_judge'; + + update_queue.instance().execute(update_queue.named('handle_assign_official_to_match', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, function (tournament_err, tournament) { + if (tournament_err) return reject(tournament_err); + app.db.matches.find({ tournament_key, _id: { $in: [...new Set([match_id, source_match_id].filter(Boolean))] } }, function (err, matches) { + if (err) return reject(err); + const match = matches.find((m) => m._id === match_id); + if (!match) return reject(new Error('Cannot find match ' + match_id)); + const state = (match.setup || {}).state; + if (state === 'preparation' || ['oncourt', 'blocked', 'finished'].includes(state)) { + return reject(new Error('Match cannot be assigned in state ' + state)); + } + const source_match = source_match_id ? matches.find((m) => m._id === source_match_id) : null; + if (source_match_id && !source_match) { + return reject(new Error('Cannot find source match ' + source_match_id)); + } + const same_match_move = !!source_match && source_match._id === match_id; + if (match.setup && match.setup[role] && (!same_match_move || source_role !== role || match.setup[role]._id !== official_id)) { + return reject(new Error('Match already has assigned ' + role)); + } + if (source_match) { + const source_setup = source_match.setup || {}; + const source_official = source_setup[source_role]; + if (!source_official || source_official._id !== official_id) { + return reject(new Error('Official is not assigned to the source match/role')); + } + } + + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err2, official) { + if (err2) return reject(err2); + if (!official) return reject(new Error('Cannot find official ' + official_id)); + + const target_setup = structuredClone(match.setup || {}); + const source_setup = source_match ? structuredClone(source_match.setup || {}) : null; + if (source_setup && source_role) { + const current_btp_id = source_setup[source_role] && source_setup[source_role].btp_id != null ? source_setup[source_role].btp_id : null; + delete source_setup[source_role]; + if (current_btp_id != null) { + source_setup[source_role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'] = current_btp_id; + } + if (same_match_move) { + delete target_setup[source_role]; + if (current_btp_id != null) { + target_setup[source_role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id'] = current_btp_id; + } + } + } + target_setup[role] = _pack_official_for_match(official, tournament || null); + delete target_setup[role === 'umpire' ? 'suppressed_umpire_btp_id' : 'suppressed_service_judge_btp_id']; + + const officialSetObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + }; + officialSetObj[role_flag] = true; + + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: officialSetObj }, + {}, + function (err3) { + if (err3) return reject(err3); + + const finish = () => { + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err6, updatedOfficial) { + if (err6) return reject(err6); + const match_ids = [...new Set([match_id, source_match_id].filter(Boolean))]; + app.db.matches.find({ tournament_key, _id: { $in: match_ids } }, function (err7, updatedMatches) { + if (err7) return reject(err7); + updatedMatches.forEach((updatedMatch) => { + notify_change(app, tournament_key, 'match_edit', { match__id: updatedMatch._id, match: updatedMatch }); + btp_manager.update_score(app, updatedMatch); + }); + notify_change(app, tournament_key, 'umpire_updated', updatedOfficial); + resolve(); + }); + }); + }; + + if (same_match_move) { + const same_guard = { _id: match_id, tournament_key, [`setup.${source_role}._id`]: official_id }; + if (source_role !== role) { + same_guard[`setup.${role}`] = { $exists: false }; + } + app.db.matches.update( + same_guard, + { $set: { setup: target_setup, btp_needsync: true } }, + {}, + function (err4, affected) { + if (err4) return reject(err4); + if (!affected) return reject(new Error('Match changed during official reassignment')); + finish(); + } + ); + return; + } + + const update_source = (cb) => { + if (!source_match) return cb(); + const source_guard = { _id: source_match_id, tournament_key, [`setup.${source_role}._id`]: official_id }; + app.db.matches.update( + source_guard, + { $set: { setup: source_setup, btp_needsync: true } }, + {}, + function (err4, affected) { + if (err4) return cb(err4); + if (!affected) return cb(new Error('Source match changed during official move')); + cb(); + } + ); + }; + + update_source(function (err4) { + if (err4) return reject(err4); + const guard = { _id: match_id, tournament_key }; + guard[`setup.${role}`] = { $exists: false }; + app.db.matches.update( + guard, + { $set: { setup: target_setup, btp_needsync: true } }, + {}, + function (err5, affected) { + if (err5) return reject(err5); + if (!affected) return reject(new Error('Match changed during official assignment')); + finish(); + } + ); + }); + } + ); + }); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => cerror.ws(ws, err)); +} + +function handle_remove_official_from_preparation_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'match_id', 'role', 'to_list'])) { + return; + } + + const { tournament_key, official_id, match_id, role, to_list, ordered_official_ids } = msg; + if (role !== 'umpire' && role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid role for remove_official_from_preparation_match: ' + role)); + } + + update_queue.instance().execute(update_queue.named('handle_remove_official_from_preparation_match', () => new Promise((resolve, reject) => { + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err, match) { + if (err) return reject(err); + if (!match) return reject(new Error('Cannot find match ' + match_id)); + const currentOfficial = match.setup && match.setup[role]; + if (!currentOfficial || currentOfficial._id !== official_id) { + return reject(new Error('Official is not assigned to this preparation role')); + } + + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err2, official) { + if (err2) return reject(err2); + if (!official) return reject(new Error('Cannot find official ' + official_id)); + + const setup = structuredClone(match.setup || {}); + const dependent_releases = _remove_official_from_setup(setup, role); + + const baseSetObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + }; + + app.db.matches.update( + { _id: match_id, tournament_key, [`setup.${role}._id`]: official_id }, + { $set: { setup, btp_needsync: true } }, + {}, + function (err3, affected) { + if (err3) return reject(err3); + if (!affected) return reject(new Error('Match changed during official removal')); + + const applyOfficialUpdates = (cb) => { + if (Array.isArray(ordered_official_ids) && ordered_official_ids.length > 0) { + const unique_ordered_ids = [...new Set(ordered_official_ids.filter(Boolean))]; + if (!unique_ordered_ids.includes(official_id)) { + unique_ordered_ids.push(official_id); + } + const now = Date.now(); + return async.eachSeries(unique_ordered_ids, (id, next) => { + const setObj = (id === official_id) ? { ...baseSetObj } : {}; + setObj[_official_list_target_field(to_list)] = _official_list_target_ts(to_list, now + unique_ordered_ids.indexOf(id), null); + app.db.umpires.update( + { _id: id, tournament_key }, + { $set: setObj }, + {}, + next + ); + }, function(series_err) { + if (series_err) return cb(series_err); + _apply_wait_releases(app, tournament_key, dependent_releases, now + unique_ordered_ids.length, cb); + }); + } + const setObj = { ...baseSetObj }; + const now = Date.now(); + setObj[_official_list_target_field(to_list)] = _official_list_target_ts(to_list, now, null); + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: setObj }, + {}, + function(update_err) { + if (update_err) return cb(update_err); + _apply_wait_releases(app, tournament_key, dependent_releases, now + 1, cb); + } + ); + }; + + applyOfficialUpdates(function (err4) { + if (err4) return reject(err4); + + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err5, updatedMatch) { + if (err5) return reject(err5); + const affected_official_ids = [...new Set( + (Array.isArray(ordered_official_ids) ? ordered_official_ids.filter(Boolean) : []) + .concat([official_id], dependent_releases.map((release) => release.official_id)) + )]; + const officialQuery = { tournament_key, _id: { $in: affected_official_ids } }; + app.db.umpires.find(officialQuery, function (err6, updatedOfficials) { + if (err6) return reject(err6); + app.db.umpires.find({ tournament_key }, function (err7, all_umpires) { + if (err7) return reject(err7); + notify_change(app, tournament_key, 'match_edit', { match__id: match_id, match: updatedMatch }); + btp_manager.update_score(app, updatedMatch); + (updatedOfficials || []).forEach((updatedOfficial) => { + notify_change(app, tournament_key, 'umpire_updated', updatedOfficial); + }); + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + resolve(); + }); + }); + }); + }); + } + ); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => cerror.ws(ws, err)); +} + +function handle_remove_official_from_match(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'official_id', 'match_id', 'role', 'to_list'])) { + return; + } + + const { tournament_key, official_id, match_id, role, to_list, ordered_official_ids } = msg; + if (role !== 'umpire' && role !== 'service_judge') { + return cerror.ws(ws, new Error('Invalid role for remove_official_from_match: ' + role)); + } + + update_queue.instance().execute(update_queue.named('handle_remove_official_from_match', () => new Promise((resolve, reject) => { + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err, match) { + if (err) return reject(err); + if (!match) return reject(new Error('Cannot find match ' + match_id)); + const currentOfficial = match.setup && match.setup[role]; + if (!currentOfficial || currentOfficial._id !== official_id) { + return reject(new Error('Official is not assigned to this role')); + } + + app.db.umpires.findOne({ _id: official_id, tournament_key }, function (err2, official) { + if (err2) return reject(err2); + if (!official) return reject(new Error('Cannot find official ' + official_id)); + + const setup = structuredClone(match.setup || {}); + const dependent_releases = _remove_official_from_setup(setup, role); + + const baseSetObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false + }; + + app.db.matches.update( + { _id: match_id, tournament_key, [`setup.${role}._id`]: official_id }, + { $set: { setup, btp_needsync: true } }, + {}, + function (err3, affected) { + if (err3) return reject(err3); + if (!affected) return reject(new Error('Match changed during official removal')); + + const applyOfficialUpdates = (cb) => { + if (Array.isArray(ordered_official_ids) && ordered_official_ids.length > 0) { + const unique_ordered_ids = [...new Set(ordered_official_ids.filter(Boolean))]; + if (!unique_ordered_ids.includes(official_id)) { + unique_ordered_ids.push(official_id); + } + const now = Date.now(); + return async.eachSeries(unique_ordered_ids, (id, next) => { + const setObj = (id === official_id) ? { ...baseSetObj } : {}; + setObj[_official_list_target_field(to_list)] = _official_list_target_ts(to_list, now + unique_ordered_ids.indexOf(id), null); + app.db.umpires.update( + { _id: id, tournament_key }, + { $set: setObj }, + {}, + next + ); + }, function(series_err) { + if (series_err) return cb(series_err); + _apply_wait_releases(app, tournament_key, dependent_releases, now + unique_ordered_ids.length, cb); + }); + } + const setObj = { ...baseSetObj }; + const now = Date.now(); + setObj[_official_list_target_field(to_list)] = _official_list_target_ts(to_list, now, null); + app.db.umpires.update( + { _id: official_id, tournament_key }, + { $set: setObj }, + {}, + function(update_err) { + if (update_err) return cb(update_err); + _apply_wait_releases(app, tournament_key, dependent_releases, now + 1, cb); + } + ); + }; + + applyOfficialUpdates(function (err4) { + if (err4) return reject(err4); + + app.db.matches.findOne({ _id: match_id, tournament_key }, function (err5, updatedMatch) { + if (err5) return reject(err5); + const affected_official_ids = [...new Set( + (Array.isArray(ordered_official_ids) ? ordered_official_ids.filter(Boolean) : []) + .concat([official_id], dependent_releases.map((release) => release.official_id)) + )]; + const officialQuery = { tournament_key, _id: { $in: affected_official_ids } }; + app.db.umpires.find(officialQuery, function (err6, updatedOfficials) { + if (err6) return reject(err6); + app.db.umpires.find({ tournament_key }, function (err7, all_umpires) { + if (err7) return reject(err7); + notify_change(app, tournament_key, 'match_edit', { match__id: match_id, match: updatedMatch }); + btp_manager.update_score(app, updatedMatch); + (updatedOfficials || []).forEach((updatedOfficial) => { + notify_change(app, tournament_key, 'umpire_updated', updatedOfficial); + }); + notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + resolve(); + }); + }); + }); + }); + } + ); + }); + }); + }))).then(() => ws.respond(msg)).catch((err) => cerror.ws(ws, err)); +} + + + +function handle_display_delete(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'display_client_id'])) { + return; + } + + const tournament_key = msg.tournament_key; + const client_id = msg.display_client_id; + + const query_remove = {client_id: client_id}; + app.db.display_court_displaysettings.remove(query_remove, {}, (err) => { + notify_change(app, tournament_key, 'delete_display', client_id); + }); + + ws.respond(msg); +} + +function handle_display_reset(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'display_client_id'])) { + return; + } + const tournament_key = msg.tournament_key; + const client_id = msg.display_client_id; + const bupws = require('./bupws'); + + bupws.restart_panel(app, tournament_key, client_id); + ws.respond(msg); +} + +function handle_edit_display_setting(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'displaysetting'])) { + return; + } + const bupws = require('./bupws'); + const querry = {id : msg.displaysetting.id}; + const displaysetting = msg.displaysetting; + const tournament_key = msg.tournament_key; + + app.db.displaysettings.update(querry, {$set: displaysetting}, {returnUpdatedDocs: true}, (err, numAffected, changed_setting) => { + + }); + + notify_change(app, msg.tournament_key, 'update_display_setting', {setting: displaysetting}); + + app.db.display_court_displaysettings.find({}, function(err, all_displays) { + if (err) { + return ws.respond(msg, err); + } + + const updated_displays = all_displays.filter( + m => (m.displaysetting_id == displaysetting.id) + ); + + updated_displays.forEach((display) => { + bupws.change_display_mode(app, tournament_key, display.client_id, displaysetting.id); + }); + + ws.respond(msg); + }); +} + +async function async_handle_delete_display_setting(app, ws, msg) { + const tournament_key = msg.tournament_key; + const setting_id = msg.setting_id; + const display = await app.db.display_court_displaysettings.findOne_async({displaysetting_id:setting_id}); + + if(display) { + ws.respond(msg, {message: `Could not delete displaysetting ${msg.setting_id} while in use`}); + return; + } + const query_remove = {id: setting_id}; + app.db.displaysettings.remove(query_remove, {}, (err) => { + notify_change(app, tournament_key, 'delete_display_setting', setting_id); + }); + + ws.respond(msg); +} + + +function handle_relocate_display(app, ws, msg) { + const tournament_key = msg.tournament_key; + const client_id = msg.display_setting_id; + const new_court_id = msg.new_court_id; + const bupws = require('./bupws'); + bupws.restart_panel(app, tournament_key, client_id, new_court_id); + ws.respond(msg); +} + +function handle_change_display_mode(app, ws, msg) { + const tournament_key = msg.tournament_key; + const client_id = msg.display_setting_id; + const new_displaysettings_id = msg.new_displaysettings_id; + const bupws = require('./bupws'); + bupws.change_display_mode(app, tournament_key, client_id, new_displaysettings_id); + ws.respond(msg); +} + + +function handle_second_call_team_two(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_call_team_two', {setup}); + + ws.respond(msg); +} + + +function handle_second_preparation_call_team_two(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'setup'])) { + return; + } + + const tournament_key = msg.tournament_key; + const setup = _extract_setup(msg.setup); + + notify_change(app, tournament_key, 'second_preparation_call_team_two', {setup}); + + ws.respond(msg); +} + + +async function async_handle_match_delete(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'id'])) { + return; + } + const tournament_key = msg.tournament_key; + let num_removed; + try { + num_removed = await app.db.matches.remove_async({_id: msg.id, tournament_key}, {}); + } catch (err) { + ws.respond(msg, err); + return; + } + if (num_removed !== 1) { + ws.respond(msg, new Error('Cannot find match ' + msg.id + ' of tournament ' + tournament_key + ' to remove in database')); + return; + } + + await app.db.courts.update_async({match_id: msg.id}, {$unset: {match_id: true}}, {}); + + notify_change(app, tournament_key, 'match_delete', {match__id: msg.id}); + ws.respond(msg); +} + +function handle_btp_fetch(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key'])) { + return; + } + + btp_manager.fetch(msg.tournament_key); + ws.respond(msg); +} + +function handle_ticker_pushall(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key'])) { + return; + } + + ticker_manager.pushall(app, msg.tournament_key); + ws.respond(msg); +} + +function handle_ticker_reset(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key'])) { + return; + } + + ticker_manager.reset(app, msg.tournament_key); + ws.respond(msg); +} + +const all_admins = []; +function _notify_queue_hang(payload) { + for (const admin_ws of all_admins) { + admin_ws.sendmsg({ + type: 'change', + tournament_key: admin_ws.last_tournament_key || 'default', + ctype: 'queue_hang_warning', + val: payload, + }); + } +} +function notify_change(app, tournament_key, ctype, val) { + let payload = val; + const announcement_change_types = new Set([ + 'match_preparation_call', + 'match_called_on_court', + 'begin_to_play_call', + 'second_call_tabletoperator', + 'second_preparation_call_tabletoperator', + 'second_call_umpire', + 'second_preparation_call_umpire', + 'second_call_servicejudge', + 'second_preparation_call_servicejudge', + 'second_call_team_one', + 'second_preparation_call_team_one', + 'second_call_team_two', + 'second_preparation_call_team_two', + 'free_announce', + ]); + if (payload && typeof payload === 'object' && announcement_change_types.has(ctype) && payload._announcement_ts == null) { + payload = { + ...payload, + _announcement_ts: Date.now(), + }; + } + if (ctype === 'match_preparation_call' && payload && typeof payload === 'object') { + console.log('[bts] debug:match_preparation_call_sent', { + match_id: payload.match__id || payload.match?._id || null, + announcement_ts: payload._announcement_ts || null, + state: payload.match?.setup?.state || null, + highlight: payload.match?.setup?.highlight || 0, + location_id: payload.match?.setup?.location_id || null, + }); + } + for (const admin_ws of all_admins) { + admin_ws.sendmsg({ + type: 'change', + tournament_key, + ctype, + val: payload, + }); + } +} + +function handle_fetch_allscoresheets_data(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key'])) { + return; + } + + const tournament_key = msg.tournament_key; + app.db.matches.find({ + tournament_key, + }, function(err, all_matches) { + if (err) { + return ws.respond(msg, err); + } + const interesting_matches = all_matches.filter( + m => (m.presses && (m.presses.length > 0)) + ); + + return ws.respond(msg, null, { + matches: interesting_matches, + }); + }); +} + +function on_connect(app, ws) { + all_admins.push(ws); + update_queue.instance().set_hang_reporter(_notify_queue_hang); +} + +function on_close(app, ws) { + if (! utils.remove(all_admins, ws)) { + serror.silent('Removing admin ws, but it was not connected!?'); + } +} + +async function async_handle_tournament_upload_logo(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'data_url', 'name'])) { + return; + } + const tournament = await app.db.tournaments.findOne_async({ key: msg.tournament_key, }); @@ -371,31 +3169,139 @@ async function async_handle_tournament_upload_logo(app, ws, msg) { const buf = Buffer.from(logo_b64, 'base64'); const logo_id = uuidv4() + '.' + ext; await promisify(fs.writeFile)(path.join(utils.root_dir(), 'data', 'logos', logo_id), buf); + const logo_name = msg.name; const [_, updated_tournament] = await app.db.tournaments.update_async( // eslint-disable-line no-unused-vars {key: msg.tournament_key}, - {$set: {logo_id}}, + {$set: {logo_id, logo_name}}, + {returnUpdatedDocs: true}); + notify_change(app, msg.tournament_key, 'logo_changed', {logo_id, logo_name}); + + return ws.respond(msg, null, {}); +} + +async function async_handle_tournament_upload_location_logo(app, ws, msg) { + if (!_require_msg(ws, msg, ['tournament_key', 'data_url', 'name', 'location_id'])) { + return; + } + + const tournament = await app.db.tournaments.findOne_async({ + key: msg.tournament_key, + }); + if (!tournament) { + ws.respond(msg, {message: `Could not find tournament ${msg.tournament_key}`}); + return; + } + + const m = /^data:(image\/[a-z+]+)(?:;base64)?,([A-Za-z0-9+/=]+)$/.exec(msg.data_url); + if (!m) { + ws.respond(msg, {message: `Invalid base64 URI, starts with ${msg.data_url.slice(0, 80)}`}); + return; + } + const mime_type = m[1]; + const logo_b64 = m[2]; + + const ext = { + 'image/gif': 'gif', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + }[mime_type]; + if (!ext) { + ws.respond(msg, {message: `Unsupported mime type ${mime_type}`}); + return; + } + + const buf = Buffer.from(logo_b64, 'base64'); + const logo_id = uuidv4() + '.' + ext; + await promisify(fs.writeFile)(path.join(utils.root_dir(), 'data', 'logos', logo_id), buf); + const logo_name = msg.name; + const location_id = msg.location_id; + const tournament_key = msg.tournament_key + + const [_, updated_tournament] = await app.db.locations.update_async( // eslint-disable-line no-unused-vars + {tournament_key, _id: location_id}, + {$set: {logo_id, logo_name}}, {returnUpdatedDocs: true}); - notify_change(app, msg.tournament_key, 'props', updated_tournament); + notify_change(app, msg.tournament_key, 'location_logo_changed', {location_id, logo_id, logo_name}); return ws.respond(msg, null, {}); } module.exports = { + handle_edit_display_setting, + async_handle_delete_display_setting, async_handle_match_delete, async_handle_tournament_upload_logo, + async_handle_tournament_upload_location_logo, + handle_begin_to_play_call, + handle_announce_match_manually, handle_btp_fetch, + handle_confirm_match_finished, + handle_normalization_add, + handle_normalization_remove, + handle_advertisement_add, + handle_advertisement_remove, + handle_tabletoperator_add, + handle_tabletoperator_move_up, + handle_tabletoperator_move_down, + handle_tabletoperator_remove, handle_fetch_allscoresheets_data, handle_create_tournament, handle_courts_add, + handle_court_edit, + handle_location_changed, handle_match_add, handle_match_edit, + handle_match_call_on_court, + handle_match_preparation_call, + async_handle_preparation_selection_get, + async_handle_preparation_selection_execute, + handle_match_player_check_in, + handle_match_participant_check_in, handle_ticker_pushall, handle_ticker_reset, + handle_free_announce, + handle_emergency_announce, + handle_official_list_move, + handle_official_edit, + handle_official_roles_edit, + handle_add_officials_to_match, + handle_add_service_judge_to_match, + assign_next_umpire_to_match, + assign_next_service_judge_to_match, + _assign_next_umpire_to_match, + _assign_next_service_judge_to_match, + handle_assign_official_to_match, + handle_assign_official_to_preparation_match, + handle_remove_official_from_match, + handle_remove_official_from_preparation_match, + handle_second_call_umpire, + handle_second_preparation_call_umpire, + handle_second_call_servicejudge, + handle_second_preparation_call_servicejudge, + handle_second_call_tabletoperator, + handle_second_preparation_call_tabletoperator, + handle_second_call_team_one, + handle_second_call_team_two, + handle_second_preparation_call_team_one, + handle_second_preparation_call_team_two, handle_tournament_get, handle_tournament_list, + handle_tournament_edit_prop, + handle_tournament_edit_scoring_format, handle_tournament_edit_props, + handle_tournament_edit_logo, + handle_display_delete, + handle_display_reset, + handle_relocate_display, + handle_change_display_mode, notify_change, + generate_tournament_web_url, on_close, on_connect, -}; \ No newline at end of file + _build_match_edit_official_sync_meta, + _collect_dependent_official_releases, + _remove_official_from_setup, +}; diff --git a/bts/btp_conn.js b/bts/btp_conn.js index 7175a03..a4c97dd 100644 --- a/bts/btp_conn.js +++ b/bts/btp_conn.js @@ -7,14 +7,17 @@ const async = require('async'); const btp_proto = require('./btp_proto'); const btp_sync = require('./btp_sync'); +const update_queue = require('./update_queue'); const serror = require('./serror'); const AUTOFETCH_TIMEOUT = 30000; const CONNECT_TIMEOUT = 5000; const WAIT_TIMEOUT = 10000; +const FETCH_QUEUE_HANG_TIMEOUT = 30000; const BTP_PORT = 9901; const BLP_PORT = 9911; + function send_raw_request(ip, port, raw_req, callback) { assert(callback); if (!ip) { @@ -69,9 +72,9 @@ function send_request(ip, port, xml_req, timeZone, callback) { class BTPConn { - constructor(app, ip, password, tkey, enabled_autofetch, readonly, is_team, timeZone) { + constructor(app, ip, password, tkey, enabled_autofetch, readonly, is_team, timeZone, autofetch_timeout_intervall) { this.app = app; - this.last_status = 'Activated'; + this.last_status = { status: 'activated', message: '' }; this.ip = ip; this.password = password; this.tkey = tkey; @@ -79,8 +82,14 @@ class BTPConn { this.terminated = false; this.enabled_autofetch = enabled_autofetch; this.autofetch_timeout = null; + this.next_fetch_ts = null; + this.fetch_in_progress = false; this.readonly = readonly; this.is_team = is_team; + const parsed_autofetch_timeout = Number(autofetch_timeout_intervall); + this.autofetch_timeout_intervall = Number.isFinite(parsed_autofetch_timeout) && parsed_autofetch_timeout > 0 + ? parsed_autofetch_timeout + : AUTOFETCH_TIMEOUT; this.connect(); } @@ -89,40 +98,91 @@ class BTPConn { return; } - this.report_status('Connecting ...'); + this.report_status('connecting','Try to establish connection to BTP.'); this.send(btp_proto.login_request(this.password), response => { - if (!response.Action || !response.Action[0] || !response.Action[0].ID[0] || (response.Action[0].ID[0] !== 'REPLY')) { - this.report_status('Invalid reply to login request'); - this.schedule_reconnect(); + if (!response || response == null || !response.Action || !response.Action[0] || !response.Action[0].ID[0] || (response.Action[0].ID[0] !== 'REPLY')) { + this.report_status('error','Invalid reply to login request'); return; } if (response.Action[0].Result[0] !== 1) { - this.report_status('Invalid password'); - this.schedule_reconnect(); + this.report_status('error', 'Invalid password'); return; } - this.report_status('Logged in.'); + this.report_status('connected','Logged in.'); this.key_unicode = response.Action[0].Unicode[0]; this.pushall(); if (this.enabled_autofetch) { - this.fetch(); + update_queue.instance().execute( + update_queue.hang_after(FETCH_QUEUE_HANG_TIMEOUT, update_queue.named('fetch', this.fetch)), + this, + true + ); } - this.schedule_fetch(); }); } - fetch() { - const ir = btp_proto.get_info_request(this.password); - this.send(ir, response => { - btp_sync.fetch(this.app, this.tkey, response, (err) => { - if (err) { - this.report_status('Synchronisations-Fehler: ' + err.stack); - console.error(err.stack); - } - }); + sync_data() { + if (this.autofetch_timeout) { + clearTimeout(this.autofetch_timeout); + this.autofetch_timeout = null; + } + this.next_fetch_ts = null; + this.publish_status(); + update_queue.instance().execute( + update_queue.hang_after(FETCH_QUEUE_HANG_TIMEOUT, update_queue.named('fetch', this.fetch)), + this, + this.enabled_autofetch + ); + } + + async fetch(connection, reschedule_fetch ) { + return new Promise((resolve, reject) => { + try { + connection.fetch_in_progress = true; + connection.next_fetch_ts = null; + connection.publish_status(); + const ir = btp_proto.get_info_request(connection.password); + connection.send(ir, async (response) => { + try { + if (response && response != null) { + const value = await btp_sync.sync_btp_data(connection.app, connection.tkey, response); + const match_utils = require('./match_utils'); + match_utils.queue_auto_execute_preparation_selections(connection.app, connection.tkey, (selectionErr) => { + if (selectionErr) { + console.warn('[bts] failed to auto select preparation matches after fetch', selectionErr && (selectionErr.stack || selectionErr.message || String(selectionErr))); + return; + } + match_utils.auto_call_matches_on_free_courts(connection.app, connection.tkey, (callErr) => { + if (callErr) { + console.warn('[bts] failed to auto call matches on free courts after fetch', callErr && (callErr.stack || callErr.message || String(callErr))); + } + }); + }); + if (reschedule_fetch == true) { + connection.schedule_fetch(); + } + connection.fetch_in_progress = false; + connection.publish_status(); + resolve(value); + } else { + connection.fetch_in_progress = false; + connection.publish_status(); + resolve(null); + } + } catch (innerError) { + connection.fetch_in_progress = false; + connection.publish_status(); + reject(innerError); + } + }); + } catch (e) { + connection.fetch_in_progress = false; + connection.publish_status(); + reject(e); + } }); } @@ -131,31 +191,39 @@ class BTPConn { return; } if (!this.enabled_autofetch) { + this.next_fetch_ts = null; + this.publish_status(); return; } - + this.next_fetch_ts = Date.now() + this.autofetch_timeout_intervall; + this.publish_status(); this.autofetch_timeout = setTimeout(() => { - this.fetch(); - this.schedule_fetch(); - }, AUTOFETCH_TIMEOUT); + this.next_fetch_ts = null; + this.publish_status(); + update_queue.instance().execute( + update_queue.hang_after(FETCH_QUEUE_HANG_TIMEOUT, update_queue.named('fetch', this.fetch)), + this, + true + ); + }, this.autofetch_timeout_intervall); } terminate() { this.terminated = true; - this.report_status('Terminated.'); + this.next_fetch_ts = null; + this.fetch_in_progress = false; + this.report_status('deactivated','Terminated.'); } send(xml_req, success_cb) { - if (this.terminated) return; - + if (this.terminated) return success_cb(null); const port = this.is_team ? BLP_PORT : BTP_PORT; send_request(this.ip, port, xml_req, this.timeZone, (err, response) => { if (err) { - this.report_status('Connection error: ' + err.message); + this.report_status('error', 'Connection error: ' + err.message); this.schedule_reconnect(); - return; + return success_cb(null); } - success_cb(response); }); } @@ -191,16 +259,31 @@ class BTPConn { clearTimeout(this.autofetch_timeout); this.autofetch_timeout = null; } + this.next_fetch_ts = null; + this.fetch_in_progress = false; + this.publish_status(); setTimeout(() => this.connect(), 500); } on_end() { - this.report_status('Verbindung verloren, versuche erneut ...'); + this.report_status('error', 'Verbindung verloren, versuche erneut ...'); this.schedule_reconnect(); } - report_status(msg) { - this.last_status = msg; + report_status(status, message) { + this.last_status = { + status: status, + message: message + }; + this.publish_status(); + } + + publish_status() { + const msg = { + ...this.last_status, + next_fetch_ts: this.next_fetch_ts, + fetch_in_progress: this.fetch_in_progress, + }; const admin = require('./admin'); admin.notify_change(this.app, this.tkey, 'btp_status', msg); } @@ -212,32 +295,57 @@ class BTPConn { async.waterfall([ (cb) => { - if (!match.setup || !match.setup.umpire_name) { + if (!match.setup) { return cb(null, null, null); } - this.app.db.umpires.findOne({ - name: match.setup.umpire_name, - tournament_key: this.tkey, - }, (err, umpire) => { - if (err) { - return cb(err); - } + const packed_umpire = match.setup.umpire; + const packed_service_judge = match.setup.service_judge; + const packed_umpire_btp_id = packed_umpire && packed_umpire.btp_id != null ? packed_umpire.btp_id : null; + const packed_service_judge_btp_id = packed_service_judge && packed_service_judge.btp_id != null ? packed_service_judge.btp_id : null; - const umpire_btp_id = umpire ? umpire.btp_id : null; - if (!match.setup.service_judge_name) { - return cb(null, umpire_btp_id, null); + if (packed_umpire_btp_id != null && (packed_service_judge_btp_id != null || !packed_service_judge || !packed_service_judge.name)) { + return cb(null, packed_umpire_btp_id, packed_service_judge_btp_id); + } + + if (!packed_umpire || !packed_umpire.name) { + if (packed_service_judge_btp_id != null) { + return cb(null, null, packed_service_judge_btp_id); + } + if (!packed_service_judge || !packed_service_judge.name) { + return cb(null, null, null); } + } + const resolveOfficialBtpId = (packed_official, done) => { + if (!packed_official) { + return done(null, null); + } + if (packed_official.btp_id != null) { + return done(null, packed_official.btp_id); + } + if (!packed_official.name) { + return done(null, null); + } this.app.db.umpires.findOne({ - name: match.setup.service_judge_name, + name: packed_official.name, tournament_key: this.tkey, - }, (err, service_judge) => { + }, (err, official) => { if (err) { - return cb(err); + return done(err); } + return done(null, official ? official.btp_id : null); + }); + }; - const service_judge_btp_id = service_judge ? service_judge.btp_id : null; + resolveOfficialBtpId(packed_umpire, (err, umpire_btp_id) => { + if (err) { + return cb(err); + } + resolveOfficialBtpId(packed_service_judge, (err2, service_judge_btp_id) => { + if (err2) { + return cb(err2); + } return cb(null, umpire_btp_id, service_judge_btp_id); }); }); @@ -258,7 +366,15 @@ class BTPConn { return cb(null, umpire_btp_id, service_judge_btp_id, court ? court.btp_id : null); }); }, - ], (err, umpire_btp_id, service_judge_btp_id, court_btp_id) => { + (umpire_btp_id, service_judge_btp_id, court_btp_id, cb) => { + this.app.db.tournaments.findOne({ key: this.tkey }, (err, tournament) => { + if (err) { + return cb(err); + } + return cb(null, umpire_btp_id, service_judge_btp_id, court_btp_id, tournament); + }); + }, + ], (err, umpire_btp_id, service_judge_btp_id, court_btp_id, tournament) => { if (err) { serror.silent('Error while fetching court/umpire: ' + err.message + '. Skipping sync of match ' + match._id); return; @@ -270,12 +386,21 @@ class BTPConn { } if (! this.key_unicode) { - serror.silent('Trying to send match data, but never logged in. Must retry later'); + //serror.silent('Trying to send match data, but never logged in. Must retry later'); return; } const req = btp_proto.update_request( - match, this.key_unicode, this.password, umpire_btp_id, service_judge_btp_id, court_btp_id); + match, + this.key_unicode, + this.password, + umpire_btp_id, + service_judge_btp_id, + court_btp_id, + { + write_match_check_in_status: tournament?.btp_settings?.check_in_per_match === true, + } + ); this.send(req, response => { const results = response.Action[0].Result; const rescode = results ? results[0] : 'no-result'; @@ -292,6 +417,62 @@ class BTPConn { }); } + + update_highlight(match) { + this.update_score(match); + } + + update_players(players) { + if (this.readonly) { + return; + } + + if (!players || players.length < 1) { + return; + } + + if (! this.key_unicode) { + //serror.silent('Trying to update player data, but never logged in. Must retry later'); + return; + } + + const req = btp_proto.update_players_request(players, this.key_unicode, this.password); + this.send(req, response => { + const results = response.Action[0].Result; + const rescode = (results && results.length > 0) ? results[0] : 'no-result'; + if (rescode === 1) { + + } else { + serror.silent('Update Player failed with error code ' + rescode); + } + }); + } + + update_courts(courts) { + if (this.readonly) { + return; + } + + if (!courts || courts.length < 1) { + return; + } + + if (! this.key_unicode) { + //serror.silent('Trying to update court data, but never logged in. Must retry later'); + return; + } + + const req = btp_proto.update_courts_request(courts, this.key_unicode, this.password); + this.send(req, response => { + const results = response.Action[0].Result; + const rescode = results ? results[0] : 'no-result'; + if (rescode === 1) { + + } else { + serror.silent('Update Courts failed with error code ' + rescode); + } + }); + } } module.exports = { diff --git a/bts/btp_manager.js b/bts/btp_manager.js index 7103a40..91261d8 100644 --- a/bts/btp_manager.js +++ b/bts/btp_manager.js @@ -22,7 +22,7 @@ function reconfigure(app, t) { app, t.btp_ip, t.btp_password, t.key, t.btp_autofetch_enabled, t.btp_readonly, - t.is_team, t.btp_timezone); + t.is_team, t.btp_timezone, t.btp_autofetch_timeout_intervall); conns_by_tkey.set(t.key, conn); } @@ -33,7 +33,7 @@ function fetch(tkey) { return; } - conn.fetch(); + conn.sync_data(); } function update_score(app, match) { @@ -51,13 +51,60 @@ function update_score(app, match) { return; } - if (typeof match.team1_won !== 'boolean') { - return; // Match not finished yet + conn.update_score(match); +} + +function update_players(app, tkey, players) { + assert(tkey); + + if (!players || players.length < 1) { + return; } - conn.update_score(match); + const conn = conns_by_tkey.get(tkey); + if (!conn) { + // Do not output an error; this happens if BTP support gets disabled + return; + } + + conn.update_players(players); } +function update_courts(app, tkey, courts) { + assert(tkey); + + if (!courts || courts.length < 1) { + return; + } + + const conn = conns_by_tkey.get(tkey); + if (!conn) { + // Do not output an error; this happens if BTP support gets disabled + return; + } + + conn.update_courts(courts); +} + +function update_highlight(app, match) { + assert(match); + const tkey = match.tournament_key; + assert(tkey); + + if (!match) { + return; + } + + const conn = conns_by_tkey.get(tkey); + if (!conn) { + // Do not output an error; this happens if BTP support gets disabled + return; + } + + conn.update_highlight(match); +} + + function init(app, cb) { app.db.tournaments.find({}, (err, tournaments) => { if (err) return cb(err); @@ -72,10 +119,14 @@ function init(app, cb) { function get_status(tkey) { const conn = conns_by_tkey.get(tkey); if (!conn) { - return 'deactivated.'; + return { status: 'deactivated', message: '', next_fetch_ts: null, fetch_in_progress: false }; } - return conn.last_status; + return { + ...conn.last_status, + next_fetch_ts: conn.next_fetch_ts, + fetch_in_progress: conn.fetch_in_progress, + }; } module.exports = { @@ -84,4 +135,7 @@ module.exports = { init, reconfigure, update_score, -}; \ No newline at end of file + update_players, + update_courts, + update_highlight, +}; diff --git a/bts/btp_parse.js b/bts/btp_parse.js index 60cbd85..2e7c56f 100644 --- a/bts/btp_parse.js +++ b/bts/btp_parse.js @@ -33,13 +33,17 @@ function filter_matches(all_btp_matches, is_league) { }); } - return all_btp_matches.filter(bm => (bm.IsMatch && bm.IsPlayable && bm.MatchNr && bm.MatchNr[0] && bm.From1)); + return all_btp_matches.filter(bm => (bm.IsPlayable && bm.MatchNr && bm.MatchNr[0] && bm.From1)); } // bts_players: Array of array of players participating. // Only for matches, not individual players // bts_winners: Array of players who have won this match. -function _calc_match_players(matches_by_pid, entries, players, bm, is_league) { +function _calc_match_players(matches_by_pid, entries, stage_entries, draws, players, bm, is_league) { + const draw = draws.get(bm.DrawID[0]); + const stage_id = draw.StageID[0]; + let entry_status = ""; + if (bm.bts_winners) { return; } @@ -50,12 +54,46 @@ function _calc_match_players(matches_by_pid, entries, players, bm, is_league) { throw new Error('Cannot find entry ' + bm.EntryID[0]); } + for (const [key, value] of stage_entries) { + if (value.StageID[0] === stage_id && value.EntryID[0] === bm.EntryID[0]) { + //console.log(`Gefunden: Key = ${key}, Value =`, value); + switch (value.Status[0]){ + case 108: + entry_status = "DNS"; + break; + case 109: + entry_status = "WDN"; + break; + + + } + break; + } + } + + const p1 = players.get(e.Player1ID[0]); assert(p1); + + if(!p1.entries) { + p1.entries = new Map([[draw.ID[0], ""]]); + } + + if(entry_status != "") { + p1.entries.set(draw.ID[0], entry_status); + } + const res = [p1]; if (e.Player2ID) { const p2 = players.get(e.Player2ID[0]); assert(p2); + if(!p2.entries) { + p2.entries = new Map([[draw.ID[0], ""]]); + } + + if(entry_status != "") { + p2.entries.set(draw.ID[0], entry_status); + } res.push(p2); } bm.bts_winners = res; @@ -67,6 +105,7 @@ function _calc_match_players(matches_by_pid, entries, players, bm, is_league) { // Normal match let p1ar, p2ar; + let p1es, p2es; if (is_league) { if (!bm.Team1Player1ID) return; @@ -88,17 +127,20 @@ function _calc_match_players(matches_by_pid, entries, players, bm, is_league) { assert(bm.From1[0]); const m1 = matches_by_pid.get(bm.DrawID[0] + '_' + bm.From1[0], is_league); assert(m1); - _calc_match_players(matches_by_pid, entries, players, m1); + _calc_match_players(matches_by_pid, entries, stage_entries, draws, players, m1); p1ar = m1.bts_winners; assert(bm.From2); assert(bm.From2[0]); const m2 = matches_by_pid.get(bm.DrawID[0] + '_' + bm.From2[0], is_league); assert(m2); - _calc_match_players(matches_by_pid, entries, players, m2, is_league); + _calc_match_players(matches_by_pid, entries, stage_entries, draws, players, m2, is_league); p2ar = m2.bts_winners; + } bm.bts_players = [p1ar, p2ar]; + bm.bts_players_entry_status = [p1es, p2es]; + if (p1ar && p2ar) { bm.bts_complete = true; if (bm.Winner) { @@ -166,13 +208,26 @@ function get_btp_state(response) { } else { all_btp_matches = btp_t.Matches ? btp_t.Matches[0].Match : []; } + + // Found Links + const all_btp_links = all_btp_matches.filter(m => { + return (m.Link != undefined); + }); + const matches_by_pid = utils.make_index(all_btp_matches, bm => _calc_match_id(bm, is_league)); const all_btp_entries = btp_t.Entries ? btp_t.Entries[0].Entry : []; + const all_btp_stage_entries = btp_t.StageEntries ? btp_t.StageEntries[0].StageEntry : []; const all_btp_events = btp_t.Events ? btp_t.Events[0].Event : []; + const all_btp_stages = btp_t.Stages ? btp_t.Stages[0].Stage : []; const all_btp_players = btp_t.Players ? btp_t.Players[0].Player : []; const all_btp_draws = btp_t.Draws ? btp_t.Draws[0].Draw : []; const all_btp_officials = btp_t.Officials ? btp_t.Officials[0].Official : []; const all_btp_courts = btp_t.Courts ? btp_t.Courts[0].Court : []; + const all_btp_locations = btp_t.Locations ? btp_t.Locations[0].Location : []; + const all_btp_settings = btp_t.Settings ? btp_t.Settings[0].Setting : []; + const all_btp_clubs = btp_t.Clubs ? btp_t.Clubs[0].Club : []; + const all_btp_districts = btp_t.Districts ? btp_t.Districts[0].District : []; + const all_btp_scoring_formats = btp_t.ScoringFormats ? btp_t.ScoringFormats[0].ScoringFormat : []; const on_court_match_ids = new Set(); for (const c of all_btp_courts) { @@ -184,7 +239,9 @@ function get_btp_state(response) { } const entries = utils.make_index(all_btp_entries, e => e.ID[0]); + const stage_entries = utils.make_index(all_btp_stage_entries, s => s.ID[0]); const events = utils.make_index(all_btp_events, e => e.ID[0]); + const stages = utils.make_index(all_btp_stages, e => e.ID[0]); const draws = utils.make_index(all_btp_draws, d => d.ID[0]); let team_matches = undefined; @@ -201,25 +258,39 @@ function get_btp_state(response) { } const matches = filter_matches(all_btp_matches, is_league); + const links = all_btp_links; const players = utils.make_index(all_btp_players, p => p.ID[0]); const officials = utils.make_index(all_btp_officials, o => o.ID[0]); const courts = utils.make_index(all_btp_courts, c => c.ID[0]); + const locations = utils.make_index(all_btp_locations, l => l.ID[0]); + const clubs = utils.make_index(all_btp_clubs, c => c.ID[0]); + const districts = utils.make_index(all_btp_districts,d => d.ID[0]); + const scoring_formats = utils.make_index(all_btp_scoring_formats, sf => sf.ID[0]); + const btp_settings = utils.make_index(all_btp_settings, s =>s.ID[0]); for (const bm of matches) { - _calc_match_players(matches_by_pid, entries, players, bm, is_league); + _calc_match_players(matches_by_pid, entries, stage_entries, draws, players, bm, is_league); } return { courts, + locations, draws, events, + stages, matches, + links, + planning_nodes: matches_by_pid, officials, is_league, match_types: new Map(Object.entries(MATCH_TYPES)), team_matches, teams, + clubs, + districts, + scoring_formats, // Testing only _matches_by_pid: matches_by_pid, + btp_settings }; } diff --git a/bts/btp_proto.js b/bts/btp_proto.js index 4a43078..b027ec0 100644 --- a/bts/btp_proto.js +++ b/bts/btp_proto.js @@ -1,13 +1,9 @@ 'use strict'; - const assert = require('assert'); const zlib = require('zlib'); - const xmldom = require('xmldom'); - const serror = require('./serror'); - function get_info_request(password) { const res = { Header: { @@ -50,8 +46,9 @@ function login_request(password) { return res; } -function update_request(match, key_unicode, password, umpire_btp_id, service_judge_btp_id, court_btp_id) { +function update_request(match, key_unicode, password, umpire_btp_id, service_judge_btp_id, court_btp_id, options = {}) { assert(key_unicode); + const write_match_check_in_status = options.write_match_check_in_status !== false; const matches = []; const res = { Header: { @@ -69,7 +66,7 @@ function update_request(match, key_unicode, password, umpire_btp_id, service_jud }, Update: { Tournament: { - Matches: matches, + Matches: matches }, }, }; @@ -77,37 +74,57 @@ function update_request(match, key_unicode, password, umpire_btp_id, service_jud res.Action.Password = password; } - assert(typeof match.team1_won === 'boolean'); - const winner = match.team1_won ? 1 : 2; assert(match.btp_match_ids); assert(match.btp_match_ids.length > 0); - assert(match.network_score); - const duration_mins = match.duration_ms ? Math.floor(match.duration_ms / 60000) : 0; const shuttle_count = match.shuttle_count; for (const btp_m_id of match.btp_match_ids) { assert(btp_m_id); - const sets = match.network_score.map(ns => { - return { - Set: { - T1: ns[0], - T2: ns[1], - }, - }; - }); + //TODO: calc Status; const m = { ID: btp_m_id.id, DrawID: btp_m_id.draw, PlanningID: btp_m_id.planning, - Sets: sets, - Winner: winner, - ScoreStatus: 0, // Won normally (TODO: correctly handle resignations etc.) - Duration: duration_mins, Status: 0, + Highlight: (match.setup.highlight ? match.setup.highlight : 0), + MatchOrder: match.match_order, // BTP also sends a boolean ScoreSheetPrinted here }; + + if(typeof match.team1_won === 'boolean') { + m.Winner = match.team1_won ? 1 : 2; + + const duration_mins = match.duration_ms ? Math.floor(match.duration_ms / 60000) : 0; + m.Duration = duration_mins; + + if(match.network_score) { + const sets = match.network_score.map(ns => { + return { + Set: { + T1: ns[0], + T2: ns[1], + }, + }; + }); + + m.Sets = sets; + } + + let scoreStatus = 0; //Won normally + if( (match.presses.length > 0 && match.presses[match.presses.length - 1].type == "retired") || + (match.presses.length > 1 && match.presses[match.presses.length - 2].type == "retired")) { + scoreStatus = 2; //retired + } + if( (match.presses.length > 0 && match.presses[match.presses.length - 1].type == "disqualified") || + (match.presses.length > 1 && match.presses[match.presses.length - 2].type == "disqualified")) { + scoreStatus = 3; //disqualified + } + + m.ScoreStatus = scoreStatus; + } + if (umpire_btp_id) { m.Official1ID = umpire_btp_id; } @@ -121,6 +138,25 @@ function update_request(match, key_unicode, password, umpire_btp_id, service_jud m.Shuttles = shuttle_count; } + + if (write_match_check_in_status && match.setup.teams.length > 1) { + if (match.setup.teams[0].players.length > 0 && match.setup.teams[0].players[0].checked_in) { + m.Status = m.Status | 0b0001; + } + + if(match.setup.teams[0].players.length > 1 && match.setup.teams[0].players[1].checked_in) { + m.Status = m.Status | 0b0010; + } + + if (match.setup.teams[1].players.length > 0 && match.setup.teams[1].players[0].checked_in) { + m.Status = m.Status | 0b0100; + } + + if(match.setup.teams[1].players.length > 1 && match.setup.teams[1].players[1].checked_in) { + m.Status = m.Status | 0b1000; + } + } + matches.push({Match: m}); } @@ -133,9 +169,117 @@ function update_request(match, key_unicode, password, umpire_btp_id, service_jud const pupdate = { ID: pid, LastTimeOnCourt: end_date, + CheckedIn: false, }; players.push({Player: pupdate}); } + + if(match.setup.tabletoperators && match.setup.tabletoperators.length > 0) { + for (const operator of match.setup.tabletoperators) { + const pupdate = { + ID: operator.btp_id, + LastTimeOnCourt: end_date, + CheckedIn: false, + }; + + players.push({Player: pupdate}); + } + } + } + return res; +} + +function update_players_request(players, key_unicode, password) { + assert(key_unicode); + const res = { + Header: { + Version: { + Hi: 1, + Lo: 1, + }, + }, + Action: { + ID: 'SENDUPDATE', + Unicode: key_unicode, + }, + Client: { + IP: 'bts', + }, + Update: { + Tournament: { + }, + }, + }; + if (password) { + res.Action.Password = password; + } + + const btp_players = []; + res.Update.Tournament.Players = btp_players; + + players.forEach((player) => { + if (player.btp_id) { + const pupdate = { + ID: player.btp_id, + CheckedIn: player.checked_in, + }; + + if (player.last_time_on_court_ts) { + const end_date = new Date(player.last_time_on_court_ts); + + pupdate.LastTimeOnCourt = end_date; + } + btp_players.push({Player: pupdate}); + } + }); + return res; +} + +function update_courts_request(courts, key_unicode, password){ + assert(key_unicode); + const courts_list = []; + const res = { + Header: { + Version: { + Hi: 1, + Lo: 1, + }, + }, + Action: { + ID: 'SENDUPDATE', + Unicode: key_unicode, + }, + Client: { + IP: 'bts', + }, + Update: { + Tournament: { + Courts: courts_list, + }, + }, + }; + + if (password) { + res.Action.Password = password; + } + + assert(courts); + assert(courts.length > 0); + + for (const court of courts) { + assert(court.btp_id); + + if (court.btp_id){ + const c = { + ID: court.btp_id + } + + if(court.btp_match_id){ + c.MatchID = court.btp_match_id; + } + + courts_list.push({Court: c}); + } } return res; @@ -322,6 +466,8 @@ module.exports = { get_info_request, login_request, update_request, + update_players_request, + update_courts_request, // Tests only _req2xml: req2xml, -}; \ No newline at end of file +}; diff --git a/bts/btp_sync.js b/bts/btp_sync.js index 625f99c..2fc0467 100644 --- a/bts/btp_sync.js +++ b/bts/btp_sync.js @@ -6,6 +6,7 @@ const async = require('async'); const btp_parse = require('./btp_parse'); const countries = require('./countries'); +const match_utils = require('./match_utils'); const utils = require('./utils'); const { fix_player } = require('./name_fixup'); @@ -18,289 +19,2316 @@ function date_str(dt) { return utils.pad(dt.year, 2, '0') + '-' + utils.pad(dt.month, 2, '0') + '-' + utils.pad(dt.day, 2, '0'); } -function craft_match(tkey, btp_id, court_map, event, draw, officials, bm, match_ids_on_court, match_types, is_league) { - if (!bm.IsMatch) { - return; +function _format_btp_match_relation_label(relation_key, bm) { + if (!bm || !bm.MatchNr || !bm.MatchNr[0]) { + return null; } + const relation = relation_key === 'winner' ? 'Gewinner' : 'Verlierer'; + const planned_time = bm.PlannedTime && bm.PlannedTime[0]; + if (!planned_time) { + return `${relation} #${bm.MatchNr[0]}`; + } + return `${relation} #${bm.MatchNr[0]} - ${date_str(planned_time)} ${time_str(planned_time)}`; +} + +function _is_displayable_btp_match_node(node) { + return !!(node && node.MatchNr && node.MatchNr[0]); +} + +function _same_btp_from_pair(a, b) { + if (!a || !b || !a.From1 || !a.From2 || !b.From1 || !b.From2) { + return false; + } + return a.From1[0] == b.From1[0] && a.From2[0] == b.From2[0]; +} + +function _find_visible_consolidation_match_for_hidden_node(draw_id, hidden_node, planning_nodes) { + if (!hidden_node || !hidden_node.From1 || !hidden_node.From2) { + return null; + } + const hidden_planning = hidden_node.PlanningID && hidden_node.PlanningID[0]; + for (const candidate of planning_nodes.values()) { + if (!candidate || candidate.DrawID[0] !== draw_id) { + continue; + } + if (hidden_planning != null && candidate.PlanningID && candidate.PlanningID[0] == hidden_planning) { + continue; + } + if (!_is_displayable_btp_match_node(candidate)) { + continue; + } + if (_same_btp_from_pair(candidate, hidden_node)) { + return candidate; + } + } + return null; +} + +function _find_incoming_matches_for_planning(draw_id, source_planning, planning_nodes) { + const incoming = []; + for (const candidate of planning_nodes.values()) { + if (!candidate || candidate.DrawID[0] !== draw_id) { + continue; + } + if (!_is_displayable_btp_match_node(candidate)) { + continue; + } + let relation = null; + if (candidate.WinnerTo && candidate.WinnerTo[0] == source_planning) { + relation = 'winner'; + } else if (candidate.LoserTo && candidate.LoserTo[0] == source_planning) { + relation = 'loser'; + } + if (relation) { + incoming.push({ candidate, relation }); + } + } + return incoming; +} + +function _find_visible_consolidation_match_for_incoming(draw_id, incoming, planning_nodes) { + if (!incoming || incoming.length < 2) { + return null; + } + const incoming_plannings = incoming + .map((entry) => entry.candidate && entry.candidate.PlanningID ? entry.candidate.PlanningID[0] : null) + .filter((planning) => planning != null); + if (incoming_plannings.length !== incoming.length) { + return null; + } + for (const candidate of planning_nodes.values()) { + if (!candidate || candidate.DrawID[0] !== draw_id) { + continue; + } + if (!_is_displayable_btp_match_node(candidate)) { + continue; + } + const candidate_sources = [ + candidate.From1 && candidate.From1[0], + candidate.From2 && candidate.From2[0], + ]; + if (candidate_sources.every((planning) => planning != null && incoming_plannings.includes(planning))) { + return candidate; + } + } + return null; +} + +function _resolve_btp_dependency_link(draw_id, source_planning, target_planning, btp_links, planning_nodes, visited = new Set()) { + if (source_planning == null) { + return null; + } + const visit_key = `${draw_id}_${source_planning}_${target_planning || ''}`; + if (visited.has(visit_key)) { + return null; + } + visited.add(visit_key); + + const direct_link = btp_links.find((l) => l.DrawID[0] === draw_id && l.PlanningID[0] == source_planning); + if (direct_link && direct_link.Link && direct_link.Link[0]) { + return direct_link.Link[0]; + } + + const incoming = _find_incoming_matches_for_planning(draw_id, source_planning, planning_nodes); + if (incoming.length > 1) { + const unique_relations = [...new Set(incoming.map((entry) => entry.relation))]; + if (unique_relations.length === 1) { + const consolidation_match = _find_visible_consolidation_match_for_incoming(draw_id, incoming, planning_nodes); + if (consolidation_match) { + return _format_btp_match_relation_label(unique_relations[0], consolidation_match); + } + } + } + + const node = planning_nodes.get(`${draw_id}_${source_planning}`); + if (!node) { + if (incoming.length === 1) { + return _format_btp_match_relation_label(incoming[0].relation, incoming[0].candidate); + } + return null; + } + + if (_is_displayable_btp_match_node(node)) { + if (target_planning != null && node.WinnerTo && node.WinnerTo[0] == target_planning) { + const direct_label = _format_btp_match_relation_label('winner', node); + if (node.PlannedTime && node.PlannedTime[0]) { + return direct_label; + } + } + if (target_planning != null && node.LoserTo && node.LoserTo[0] == target_planning) { + const direct_label = _format_btp_match_relation_label('loser', node); + if (node.PlannedTime && node.PlannedTime[0]) { + return direct_label; + } + } + } + + const consolidation_match = _find_visible_consolidation_match_for_hidden_node(draw_id, node, planning_nodes); + if (consolidation_match) { + if (target_planning != null && node.WinnerTo && node.WinnerTo[0] == target_planning) { + return _format_btp_match_relation_label('winner', consolidation_match); + } + if (target_planning != null && node.LoserTo && node.LoserTo[0] == target_planning) { + return _format_btp_match_relation_label('loser', consolidation_match); + } + if (consolidation_match.WinnerTo && consolidation_match.WinnerTo[0] == source_planning) { + return _format_btp_match_relation_label('winner', consolidation_match); + } + if (consolidation_match.LoserTo && consolidation_match.LoserTo[0] == source_planning) { + return _format_btp_match_relation_label('loser', consolidation_match); + } + } + + if (_is_displayable_btp_match_node(node)) { + if (target_planning != null && node.WinnerTo && node.WinnerTo[0] == target_planning) { + return _format_btp_match_relation_label('winner', node); + } + if (target_planning != null && node.LoserTo && node.LoserTo[0] == target_planning) { + return _format_btp_match_relation_label('loser', node); + } + } + + if (node.From1 && node.From1[0]) { + const nested_from1 = _resolve_btp_dependency_link(draw_id, node.From1[0], source_planning, btp_links, planning_nodes, visited); + if (nested_from1) { + return nested_from1; + } + } + if (node.From2 && node.From2[0]) { + const nested_from2 = _resolve_btp_dependency_link(draw_id, node.From2[0], source_planning, btp_links, planning_nodes, visited); + if (nested_from2) { + return nested_from2; + } + } + + return null; +} + +async function craft_match(app, tkey, btp_id, location_map, court_map, event, stage, scoring_formats, draw, btp_links, planning_nodes, officials, clubs, districts, bm, match_ids_on_court, match_types, is_league) { + return new Promise((resolve, reject) => { + const stournament = require('./stournament'); // avoid dependency cycle + + const gtid = event.GameTypeID[0]; + assert((gtid === 1) || (gtid === 2)); + + const scheduled_time_str = (bm.PlannedTime ? time_str(bm.PlannedTime[0]) : undefined); + const scheduled_date = (bm.PlannedTime ? date_str(bm.PlannedTime[0]) : undefined); + const phase_name_raw = (bm.RoundName && bm.RoundName[0] ? bm.RoundName[0] : undefined); + var match_name = phase_name_raw; + var event_name = event.Name[0]; + const teams = _craft_teams(bm, clubs, districts); + + const rounds = new Map(); + if(draw.Position[0] > 1) { + rounds.set("Finale", [ 1, 2]); + rounds.set("HF", [ 1, 4]); + rounds.set("VF", [ 1, 8]); + rounds.set("R16", [ 1, 16]); + rounds.set("R32", [ 1, 32]); + } + rounds.set("3/4", [ 3, 4]); + rounds.set("5/6", [ 5, 6]); + rounds.set("7/8", [ 7, 8]); + rounds.set("9/10", [ 9, 10]); + rounds.set("11/12", [11, 12]); + rounds.set("13/14", [13, 14]); + rounds.set("15/16", [15, 16]); + rounds.set("17/18", [17, 18]); + rounds.set("19/20", [19, 20]); + rounds.set("21/22", [21, 22]); + rounds.set("23/24", [23, 24]); + rounds.set("25/26", [25, 26]); + rounds.set("27/28", [27, 28]); + rounds.set("29/30", [29, 30]); + rounds.set("31/32", [31, 32]); + rounds.set("5/8", [ 5, 8]); + rounds.set("9/12", [ 9, 12]); + rounds.set("13/16", [13, 16]); + rounds.set("17/20", [17, 20]); + rounds.set("21/24", [21, 24]); + rounds.set("25/28", [25, 28]); + rounds.set("29/32", [29, 32]); + rounds.set("9/16", [ 9, 16]); + rounds.set("17/24", [17, 24]); + rounds.set("25/32", [25, 32]); + rounds.set("17/32", [17, 32]); + rounds.set("CP- R16", [ 5, 16]); + rounds.set("CP- VF", [ 5, 12]); + + let phase_block_key = 'UNKNOWN'; + if (phase_name_raw) { + if (/^G\d+$/.test(phase_name_raw)) { + phase_block_key = phase_name_raw; + } else if (['R64', 'R32', 'R16', 'VF', 'HF'].includes(phase_name_raw)) { + phase_block_key = phase_name_raw; + } else if (phase_name_raw === 'CP- R16') { + phase_block_key = 'CP-R16'; + } else if (phase_name_raw === 'CP- VF') { + phase_block_key = 'CP-VF'; + } else if (['Finale', '3/4'].includes(phase_name_raw)) { + phase_block_key = 'FR'; + } + } + + if(match_name && rounds.get(match_name)) { + const best_place = rounds.get(match_name)[0] + draw.Position[0] - 1; + const lowes_place = rounds.get(match_name)[1] + draw.Position[0] - 1; + + match_name = best_place + "/" + lowes_place; + } else { + event_name = (event.Name[0] === draw.Name[0]) ? draw.Name[0] : event.Name[0] + (draw.DrawTypeID[0] > 1 ? ' - ' + draw.Name[0] : ""); + } + + const btp_player_ids = []; + + if (bm.bts_players && bm.bts_players.length > 0) { + for (const team of bm.bts_players) { + if (team && team.length > 0) { + for (const p of team) { + btp_player_ids.push(p.ID[0]); + } + } + } + } + + const links = {}; + try { + links.from1 = bm.From1[0]; + links.from2 = bm.From2[0]; + + if (bm.WinnerTo) { + links.winner_to = bm.WinnerTo[0]; + } + if (bm.LoserTo) { + links.loser_to = bm.LoserTo[0]; + } + if (bm.Link) { + links.from_link = bm.Link; + } + } catch (err) { + console.log(err); + } + + if (teams[0].players.length < 1) { + links.from1_link = _resolve_btp_dependency_link(bm.DrawID[0], links.from1, bm.PlanningID[0], btp_links, planning_nodes); + } + + if (teams[1].players.length < 1) { + links.from2_link = _resolve_btp_dependency_link(bm.DrawID[0], links.from2, bm.PlanningID[0], btp_links, planning_nodes); + } + + + let scoring_format = null; + + if (stage.ScoringFormat) { + scoring_format = scoring_formats.get(Number(stage.ScoringFormat)); + } else { + scoring_format = findDefaultScoringFormat(scoring_formats); + } + + // Fallback, falls gar nichts gefunden wird + if (!scoring_format) { + scoring_format = fallbackScoringFormat(); + } + + const setup = { + is_match: (bm.IsMatch && bm.IsMatch[0] ? true : false), + incomplete: !bm.bts_complete, + is_doubles: (gtid === 2), + match_num: bm.MatchNr[0], + scoring_format: scoring_format, + team_competition: false, + event_name, + teams, + warmup: "none", + links: links, + highlight: bm.Highlight[0], + phase_name_raw, + phase_block_key, + }; + + app.db.tournaments.findOne({ key: tkey }, (err, tournament) => { + + if (err) { + reject(err); + } + if (tournament.warmup) { + setup.warmup = tournament.warmup; + } + if (tournament.warmup_ready) { + setup.warmup_ready = tournament.warmup_ready; + } + if (tournament.warmup_start) { + setup.warmup_start = tournament.warmup_start; + } + if (tournament.btp_settings.check_in_per_match && teams.length > 1 && teams[0].players.length > 0) { + teams[0].players[0].checked_in = (bm.Status & 0b0001) > 0; + teams[0].players[0].check_in_per_match = true; + if (teams[0].players.length > 1) { + teams[0].players[1].checked_in = (bm.Status & 0b0010) > 0; + teams[0].players[1].check_in_per_match = true; + } + + if (teams[1].players.length > 0) { + teams[1].players[0].checked_in = (bm.Status & 0b0100) > 0; + teams[1].players[0].check_in_per_match = true; + if (teams[1].players.length > 1) { + teams[1].players[1].checked_in = (bm.Status & 0b1000) > 0; + teams[1].players[1].check_in_per_match = true; + } + } + } + if (match_name) { + setup.match_name = match_name; + } + + if (scheduled_time_str) { + setup.scheduled_time_str = scheduled_time_str; + } + if (scheduled_date) { + setup.scheduled_date = scheduled_date; + } + if (bm.CourtID) { + const btp_court_id = bm.CourtID[0]; + const court_id = court_map.get(btp_court_id); + assert(court_id); + setup.court_id = court_id; + setup.now_on_court = match_ids_on_court.has(bm.ID[0]); + } + if(bm.LocationID) { + const btp_location_id = bm.LocationID[0]; + const location_id = location_map.get(btp_location_id); + assert(location_id); + setup.location_id = location_id; + } + if(setup.highlight != 0) { + stournament.get_locations(app.db, tkey, function (err, all_locations) { + const location = all_locations.find(loc => loc.highlight === setup.highlight); + if(location) { + setup.location_id = location._id; + } + }); + } + if (bm.Official1ID) { + const official_id = bm.Official1ID[0]; + const o = get_umpire(app, tkey, officials, official_id) || build_fallback_official(official_id, tkey); + if (o) { + setup.umpire = { ...o, checked_in: !!o.checked_in }; + } + } + if (bm.Official2ID) { + const official_id = bm.Official2ID[0]; + const o = get_umpire(app, tkey, officials, official_id) || build_fallback_official(official_id, tkey); + if (o) { + setup.service_judge = { ...o, checked_in: !!o.checked_in }; + } + } + + const btp_match_ids = [{ + id: bm.ID[0], + nr: bm.MatchNr[0], + draw: bm.DrawID[0], + planning: bm.PlanningID[0], + }]; + + const match = { + tournament_key: tkey, + btp_id, + btp_match_ids, + btp_player_ids, + setup, + }; + match.team1_won = undefined; + match.btp_winner = undefined; + if (bm.Winner) { + match.btp_winner = bm.Winner[0]; + match.team1_won = (match.btp_winner === 1); + } + if (bm.Sets) { + match.network_score = _parse_score(bm); + } + if (bm.Shuttles) { + match.shuttle_count = bm.Shuttles[0]; + } + if (bm.DisplayOrder) { + match.match_order = bm.DisplayOrder[0]; + } + match._id = 'btp_' + btp_id; + resolve(match); + }); + }); +} + +function findDefaultScoringFormat(scoringFormatMap) { + + for (const entry of scoringFormatMap.entries()) { + const id = entry[0]; + const sf = entry[1]; + + if (sf && sf.isDefault) return sf; + } + return null; +} + +function mergeLocalMatchIntoBtpMatch(current_match, match) { + if (current_match.team1_won === null) { + current_match.team1_won = undefined; + } + + if (current_match.btp_winner) { + match.setup.state = 'finished'; + } + if (typeof current_match.team1_won === 'boolean' || current_match.btp_winner || current_match.btp_needsync) { + match.setup.now_on_court = false; + match.setup.state = 'finished'; + } else if (current_match.setup.now_on_court === true) { + // Keep the local on-court state until the result is explicitly confirmed. + match.setup.now_on_court = true; + if (current_match.setup.state === 'blocked') { + match.setup.state = 'blocked'; + } else if (current_match.setup.called_timestamp) { + match.setup.state = 'oncourt'; + } + } + + if (!match.setup.court_id && current_match.setup && current_match.setup.court_id) { + match.setup.court_id = current_match.setup.court_id; + } + + if (!match.network_score && current_match.network_score) { + match.network_score = current_match.network_score; + } + + if (current_match.setup.called_timestamp) { + match.setup.called_timestamp = current_match.setup.called_timestamp; + } + + const local_preparation_active = + current_match.setup && + current_match.setup.state === 'preparation' && + Number(current_match.setup.highlight) > 0 && + current_match.setup.preparation_call_timestamp; + + if (local_preparation_active) { + match.setup.preparation_call_timestamp = current_match.setup.preparation_call_timestamp; + match.setup.state = 'preparation'; + } + if (current_match.setup.needs_preparation_successor != null) { + match.setup.needs_preparation_successor = current_match.setup.needs_preparation_successor; + } + if (current_match.setup.needs_preparation_successor_ts != null) { + match.setup.needs_preparation_successor_ts = current_match.setup.needs_preparation_successor_ts; + } + + const suppression_active = current_match.btp_needsync === true; + const suppressed_umpire_btp_id = suppression_active ? current_match.setup.suppressed_umpire_btp_id : null; + const suppressed_service_judge_btp_id = suppression_active ? current_match.setup.suppressed_service_judge_btp_id : null; + if (suppressed_umpire_btp_id != null) { + if (match.setup.umpire && String(match.setup.umpire.btp_id) === String(suppressed_umpire_btp_id)) { + delete match.setup.umpire; + match.setup.suppressed_umpire_btp_id = suppressed_umpire_btp_id; + } + } + if (suppressed_service_judge_btp_id != null) { + if (match.setup.service_judge && String(match.setup.service_judge.btp_id) === String(suppressed_service_judge_btp_id)) { + delete match.setup.service_judge; + match.setup.suppressed_service_judge_btp_id = suppressed_service_judge_btp_id; + } + } + + if (current_match.btp_needsync === true && current_match.setup.umpire && !match.setup.umpire && suppressed_umpire_btp_id == null) { + match.setup.umpire = current_match.setup.umpire; + } + + if (current_match.setup.umpire && match.setup.umpire && + current_match.setup.umpire.btp_id == match.setup.umpire.btp_id && + current_match.btp_needsync === true && + ('checked_in' in current_match.setup.umpire)) { + match.setup.umpire.checked_in = current_match.setup.umpire.checked_in; + } + + if (current_match.btp_needsync === true && current_match.setup.service_judge && !match.setup.service_judge && suppressed_service_judge_btp_id == null) { + match.setup.service_judge = current_match.setup.service_judge; + } + + if (current_match.setup.service_judge && match.setup.service_judge && + current_match.setup.service_judge.btp_id == match.setup.service_judge.btp_id && + current_match.btp_needsync === true && + ('checked_in' in current_match.setup.service_judge)) { + match.setup.service_judge.checked_in = current_match.setup.service_judge.checked_in; + } + + if (current_match.setup.tabletoperators) { + match.setup.tabletoperators = current_match.setup.tabletoperators; + } + + for (let team_index = 0; team_index < Math.min(current_match.setup.teams.length, match.setup.teams.length); team_index++) { + for (let player_index = 0; player_index < Math.min(current_match.setup.teams[team_index].players.length, match.setup.teams[team_index].players.length); player_index++) { + if (current_match.setup.teams[team_index].players[player_index].now_playing_on_court != undefined) { + match.setup.teams[team_index].players[player_index].now_playing_on_court = current_match.setup.teams[team_index].players[player_index].now_playing_on_court; + } + + if (current_match.setup.teams[team_index].players[player_index].now_tablet_on_court != undefined) { + match.setup.teams[team_index].players[player_index].now_tablet_on_court = current_match.setup.teams[team_index].players[player_index].now_tablet_on_court; + } + + if (current_match.setup.teams[team_index].players[player_index].tablet_break_active != undefined) { + match.setup.teams[team_index].players[player_index].tablet_break_active = current_match.setup.teams[team_index].players[player_index].tablet_break_active; + } + + if (current_match.btp_needsync === true && + current_match.setup.teams[team_index].players[player_index].checked_in != undefined) { + match.setup.teams[team_index].players[player_index].checked_in = current_match.setup.teams[team_index].players[player_index].checked_in; + } + } + } + + return match; +} + +function _craft_team(par) { + if (!par) { + return { players: [] }; + } + + const players = par.map(p => { + const asian_name = !!(p.Asianname && p.Asianname[0]); + const pres = { asian_name }; + if (p.Firstname && p.Lastname) { + if (asian_name) { + pres.name = p.Lastname[0].toUpperCase() + ' ' + p.Firstname[0]; + } else { + pres.name = p.Firstname[0] + ' ' + p.Lastname[0]; + } + + pres.firstname = p.Firstname[0]; + pres.lastname = p.Lastname[0]; + } else if (p.Lastname) { + pres.name = p.Lastname[0]; + pres.lastname = p.Lastname[0]; + pres.firstname = ''; + } else if (p.Firstname) { + pres.name = p.Firstname[0]; + pres.lastname = p.Firstname[0]; + pres.firstname = ''; + } + + + if (p.ID && p.ID[0]) { + pres.btp_id = p.ID[0]; + } + + if (p.Country && p.Country[0]) { + pres.nationality = p.Country[0]; + } + + //if (p.entries) { + // pres.entries = p.entries; + //} + + if (p.LastTimeOnCourt && p.LastTimeOnCourt[0]) { + let date = new Date(p.LastTimeOnCourt[0].year, + p.LastTimeOnCourt[0].month - 1, + p.LastTimeOnCourt[0].day, + p.LastTimeOnCourt[0].hour, + p.LastTimeOnCourt[0].minute, + p.LastTimeOnCourt[0].second, + p.LastTimeOnCourt[0].ms); + pres.last_time_on_court_ts = date.getTime(); + } + + if (p.CheckedIn && p.CheckedIn.length > 0) { + pres.checked_in = p.CheckedIn[0]; + } + + try{ + const club = this.clubs.get(p.ClubID[0]); + const district = this.districts.get(club.DistrictID[0]); + const state_by_district = district.Name[0].split("-")[0]; + + var state = (state_by_district ? state_by_district : (p.State && p.Satate.length > 0 ? p.State[0] : undefined)); + if (state) { + switch (state) { + case 'BAW' : { + pres.state = "Baden-Württemberg"; + break; + } case 'BAY' : { + pres.state = "Bayern"; + break; + } case 'BBB': { + pres.state = "Berlin-Brandenburg"; + break; + } case 'BRE': { + pres.state = "Bremen"; + break; + } case 'HAM': { + pres.state = "Hamburg"; + break; + } case 'HES': { + pres.state = "Hessen"; + break; + } case 'MVP': { + pres.state = "Mecklenburg-Vorpommern"; + break; + } case 'NIS': { + pres.state = "Niedersachsen"; + break; + } case 'NRW': { + pres.state = "Nordrhein-Westfalen"; + break; + } case 'RHP': { + pres.state = "Rheinhessen-Pfalz"; + break; + } case 'RHL': { + pres.state = "Rheinland"; + break; + } case 'SAA': { + pres.state = "Saarland"; + break; + } case 'SAC': { + pres.state = "Sachsen"; + break; + } case 'SAH': { + pres.state = "Sachsen-Anhalt"; + break; + } case 'SLH': { + pres.state = "Schleswig-Holstein"; + break; + } case 'THÜ': { + pres.state = "Thüringen"; + break; + } + default: + pres.state = state + } + } + } catch (error) + { + } + fix_player(pres); + return pres; + }); + + const tres = { + players, + }; + + if ((players.length === 2) && (players[0].nationality != players[1].nationality)) { + tres.name = countries.lookup(players[0].nationality) + ' / ' + countries.lookup(players[1].nationality); + } else if ((players.length > 0) && (players[0].nationality)) { + tres.name = countries.lookup(players[0].nationality); + } + + return tres; +} + +function _craft_teams(bm, clubs, districts) { + assert(bm.bts_players); + assert(clubs); + assert(districts); + + let res = bm.bts_players.map(_craft_team, {clubs: clubs, districts: districts}); + + return res; +} + +function _parse_score(bm) { + assert(bm.Sets); + assert(bm.Sets[0]); + assert(bm.Sets[0].Set); + + return bm.Sets[0].Set.map(s => [s.T1[0], s.T2[0]]); +} + +async function cleanup_entities(app, tkey, btp_state, callback) { + + const { draws, events } = btp_state; + var btpMaptches = {} + btp_state.matches.forEach(function (match) { + btpMaptches[calculate_btp_match_id(tkey, match, draws, events)] = true; + }) + + app.db.matches.find({ 'tournament_key': tkey }, (err, matches, cb) => { + if (err) { + return callback(err); + } + matches.forEach(function (match) { + + if (btpMaptches[match.btp_id] === true) { + //TODO invert query + return; + } else { + const match_q = { _id: match._id }; + app.db.matches.remove(match_q, {}, (err) => { + const admin = require('./admin'); + admin.notify_change(app, match.tournament_key, 'match_remove', { + match__id: match._id + }); + return; + }); + + } + }) + }); + var btpUmpires = {} + btp_state.officials.forEach(function (umpire) { + btpUmpires[umpire.ID[0]] = true; + }) + + const querry = { 'tournament_key': tkey }; + app.db.umpires.find(querry).exec((err, umpires) => { + if (err) { + return callback(err); + } + umpires.forEach(function (umpire) { + if (btpUmpires[umpire.btp_id] === true) { + return; + } + const allListsNull = + !umpire.is_planed_as_umpire && + !umpire.is_planed_as_service_judge && + umpire.umpire_on_court == null && + umpire.service_judge_on_court == null && + umpire.umpire_wait == null && + umpire.service_judge_wait == null && + umpire.umpire_pause == null && + umpire.service_judge_pause == null; + + const next_inactive_list = allListsNull ? (umpire.inactive_list || Date.now()) : null; + if (umpire.inactive_list === next_inactive_list) { + return; + } + + app.db.umpires.update( + { _id: umpire._id }, + { $set: { inactive_list: next_inactive_list } }, + { returnUpdatedDocs: true }, + (err, numAffected, changed_umpire) => { + if (err) { + console.error(err); + return; + } + const admin = require('./admin'); + admin.notify_change(app, tkey, 'umpire_updated', changed_umpire); + } + ); + }); + }); + + + return callback(null); +} + + +function calculate_btp_match_id(tkey, bm, draws, events) { + const draw = draws.get(bm.DrawID[0]); + const event = events.get(draw.EventID[0]); + const discipline_name = (event.Name[0] === draw.Name[0]) ? draw.Name[0] : event.Name[0] + '_' + draw.Name[0]; + return tkey + '_' + discipline_name + '_' + bm.ID[0]; +} + +function build_match_update_fields(match) { + return { + btp_match_ids: match.btp_match_ids, + btp_player_ids: match.btp_player_ids, + setup: match.setup, + team1_won: match.team1_won, + btp_winner: match.btp_winner, + btp_needsync: match.btp_needsync, + network_score: match.network_score, + network_team1_left: match.network_team1_left, + network_team1_serving: match.network_team1_serving, + network_teams_player1_even: match.network_teams_player1_even, + presses: match.presses, + duration_ms: match.duration_ms, + end_ts: match.end_ts, + shuttle_count: match.shuttle_count, + match_order: match.match_order, + }; +} + +function build_match_update_operations(current_match, next_match) { + const current_fields = build_match_update_fields(current_match); + const next_fields = build_match_update_fields(next_match); + const setObj = {}; + const unsetObj = {}; + + function append_update_ops(current_value, next_value, path) { + if (utils.deep_equal(current_value, next_value)) { + return; + } + + if (next_value === undefined) { + unsetObj[path] = true; + return; + } + + if (current_value === undefined) { + setObj[path] = next_value; + return; + } + + const current_is_array = Array.isArray(current_value); + const next_is_array = Array.isArray(next_value); + if (current_is_array || next_is_array) { + if (!current_is_array || !next_is_array || current_value.length !== next_value.length) { + setObj[path] = next_value; + return; + } + for (let i = 0; i < next_value.length; i++) { + append_update_ops(current_value[i], next_value[i], `${path}.${i}`); + } + return; + } + + const current_is_object = current_value && typeof current_value === 'object'; + const next_is_object = next_value && typeof next_value === 'object'; + if (current_is_object && next_is_object) { + const keys = new Set([...Object.keys(current_value), ...Object.keys(next_value)]); + keys.forEach((key) => append_update_ops(current_value[key], next_value[key], `${path}.${key}`)); + return; + } + + setObj[path] = next_value; + } + + Object.keys(next_fields).forEach((key) => append_update_ops(current_fields[key], next_fields[key], key)); + + const update = {}; + if (Object.keys(setObj).length > 0) { + update.$set = setObj; + } + if (Object.keys(unsetObj).length > 0) { + update.$unset = unsetObj; + } + return update; +} + + +function get_umpires(app, tkey) { + return new Promise((resolve, reject) => { + const querry = { 'tournament_key': tkey }; + app.db.umpires.find(querry).exec((err, umpires) => { + if (err) { + return reject(err); + } + return resolve(umpires); + }); + }); +} + +function get_umpire(app, tkey, umpires , btp_id) { + var returnValue = null; + umpires.forEach((umpire) => { + if (umpire.btp_id != null && String(umpire.btp_id) === String(btp_id)) { + returnValue = umpire; + } + }); + return returnValue; +} + +function build_fallback_official(official_id, tkey) { + return { + _id: `${tkey}_btp_${official_id}`, + tournament_key: tkey, + btp_id: official_id, + firstname: '', + surname: '', + name: `BTP Official ${official_id}`, + country: '', + status: 'ready' + }; +} + +async function integrate_matches(app, tkey, btp_state, scoring_formats, location_map, court_map, callback) { + const admin = require('./admin'); // avoid dependency cycle + const match_utils = require('./match_utils'); + const { draws, events, stages } = btp_state; + + const match_ids_on_court = calculate_match_ids_on_court(btp_state); + + const officials = await get_umpires(app, tkey); + + const matches_to_add = []; + const matches_player_changed = []; + const matches_on_court = []; + const matches_incomplete = []; + const clubs = btp_state.clubs; + const districts = btp_state.districts; + let changes = false; + + async.each(btp_state.matches, function (bm, cb) { + const draw = draws.get(bm.DrawID[0]); + assert(draw); + + const event = events.get(draw.EventID[0]); + assert(event); + + const stage = stages.get(draw.StageID[0]); + assert(stage); + + const btp_id = calculate_btp_match_id(tkey, bm, draws, events); + + if (!(bm.IsMatch && bm.IsMatch[0])) { + cb(null); + return; + } + + if (bm.ReverseHomeAway) { + cb(null); + return; + } + const query = { + btp_id, + tournament_key: tkey, + }; + // TODO get all matches upfront here + app.db.matches.findOne(query, (err, cur_match) => { + if (err) { + console.log(err); + cb(null); + return; + }; + if (cur_match && cur_match.btp_needsync) { + cb(null); + return; + } + + craft_match(app, tkey, btp_id, location_map, court_map, event, stage, scoring_formats, draw, btp_state.links, btp_state.planning_nodes, officials, clubs, districts, bm, match_ids_on_court).then(match => { + + + match.setup.state = 'unscheduled'; + if (match.setup.now_on_court === true) { + match.setup.state = 'oncourt'; + matches_on_court.push(match); + } + else if (match.setup.incomplete == true) { + match.setup.state = 'incomplete'; + matches_incomplete.push(match); + } + else if (match.setup.scheduled_date && match.setup.scheduled_time_str) { + match.setup.state = 'scheduled'; + } + + if (cur_match) { + app.db.matches.findOne({ _id: cur_match._id, tournament_key: tkey }, (err, latest_match) => { + if (err) { + cb(err); + return; + } + const current_match = latest_match || cur_match; + + match = mergeLocalMatchIntoBtpMatch(current_match, match); + + for (let team_index = 0; team_index < Math.min(current_match.setup.teams.length, match.setup.teams.length); team_index++) { + for (let player_index = 0; player_index < Math.min(current_match.setup.teams[team_index].players.length, match.setup.teams[team_index].players.length); player_index++) { + + if (current_match.setup.teams[team_index].players[player_index].last_time_on_court_ts || match.setup.teams[team_index].players[player_index].last_time_on_court_ts) { + const current_ts = current_match.setup.teams[team_index].players[player_index].last_time_on_court_ts || 0; + const next_ts = match.setup.teams[team_index].players[player_index].last_time_on_court_ts || 0; + const max_ts = Math.max(current_ts, next_ts); + current_match.setup.teams[team_index].players[player_index].last_time_on_court_ts = max_ts; + match.setup.teams[team_index].players[player_index].last_time_on_court_ts = max_ts; + } + } + } + + match.btp_needsync = current_match.btp_needsync; + match.network_team1_left = current_match.network_team1_left; + match.network_team1_serving = current_match.network_team1_serving; + match.network_teams_player1_even = current_match.network_teams_player1_even; + match.presses = current_match.presses; + match.duration_ms = current_match.duration_ms; + match.end_ts = current_match.end_ts; + + if (match.setup.now_on_court === false) { + if (current_match.setup.warmup) { + match.setup.warmup = current_match.setup.warmup; + } + + if (current_match.setup.warmup_ready) { + match.setup.warmup_ready = current_match.setup.warmup_ready; + } + + if (current_match.setup.warmup_start) { + match.setup.warmup_start = current_match.setup.warmup_start; + } + } + + for (let team_index = 0; team_index < Math.min(current_match.setup.teams.length, match.setup.teams.length); team_index++) { + for (let player_index = 0; player_index < Math.min(current_match.setup.teams[team_index].players.length, match.setup.teams[team_index].players.length); player_index++) { + if ('tablet_break_active' in current_match.setup.teams[team_index].players[player_index]) { + match.setup.teams[team_index].players[player_index].tablet_break_active = current_match.setup.teams[team_index].players[player_index].tablet_break_active; + } + } + } + + if (utils.plucked_deep_equal(match, current_match, Object.keys(match), true)) { + cb(null); + return; + } + + let only_change_check_in = false; + let result_enterd_in_btp = false; + let match_player_changed = false; + const current_match_for_check_in_compare = JSON.parse(JSON.stringify(current_match)); + + for (let team_index = 0; team_index < Math.min(current_match.setup.teams.length, match.setup.teams.length); team_index++) { + if(current_match.setup.teams[team_index].players.length < match.setup.teams[team_index].players.length){ + for (let player_index = 0; player_index < match.setup.teams[team_index].players.length; player_index++) { + match_player_changed = true; + } + } + for (let player_index = 0; player_index < Math.min(current_match.setup.teams[team_index].players.length, match.setup.teams[team_index].players.length); player_index++) { + current_match_for_check_in_compare.setup.teams[team_index].players[player_index].checked_in = match.setup.teams[team_index].players[player_index].checked_in; + if(match.setup.teams[team_index].players[player_index].btp_id != current_match.setup.teams[team_index].players[player_index].btp_id) { + match_player_changed = true; + } + } + } + + if (!current_match.team1_won && current_match.team1_won != match.team1_won) { + if (!match.end_ts) { + result_enterd_in_btp = true; + match.setup.warmup = 'none'; + match.end_ts = Date.now(); + + app.db.tournaments.findOne({ key: tkey }, async (err, tournament) => { + if (err) { + return callback(err); + } + if ((tournament.tabletoperator_enabled && tournament.tabletoperator_enabled == true)) { + const match_utils = require('./match_utils'); + match_utils.reset_player_tabletoperator(app, tkey, match._id, match.end_ts); + } + }); + } + } + + if (utils.plucked_deep_equal(match, current_match_for_check_in_compare, Object.keys(match), true)) { + only_change_check_in = true; + } + + if(match_player_changed) { + matches_player_changed.push(match); + } + + const update_ops = build_match_update_operations(current_match, match); + if (Object.keys(update_ops).length === 0) { + cb(null); + return; + } + + app.db.matches.update({ _id: current_match._id }, update_ops, {}, (err) => { + if (err) { + cb(err); + return; + } + + if (match.setup.is_match) { + if (!only_change_check_in || result_enterd_in_btp) { + changes = true; + admin.notify_change(app, match.tournament_key, 'match_edit', { + match__id: match._id, + match: match + }); + } else { + admin.notify_change(app, match.tournament_key, 'update_player_status', { + match__id: match._id, + btp_winner: match.btp_winner, + setup: match.setup + }); + } + } + cb(null); + }); + }); + return; + } + changes = true; + matches_to_add.push(match); + cb(null) + return; + }, error => { + cb(null); + return; + }); + }); + }, (error) => { + if (error) { + console.error(error); + } + + matches_player_changed.forEach(async (match_player_changed) => { + let match = match_player_changed; + matches_on_court.forEach(async (match_on_court) => { + const changed_match_on_court = await match_utils.calc_match_set_player_on_court(match, match_on_court.setup); + if(changed_match_on_court != null) { + match = changed_match_on_court; + } + const changed_match_tablet_operator = await match_utils.calc_match_set_player_on_tablet(match, match_on_court.setup); + if(changed_match_tablet_operator != null) { + match = changed_match_tablet_operator; + } + }); + }); + + matches_to_add.forEach(async (match_to_add) => { + let match = match_to_add; + matches_on_court.forEach(async (match_on_court) => { + const changed_match_on_court = await match_utils.calc_match_set_player_on_court(match, match_on_court.setup); + if(changed_match_on_court != null) { + match = changed_match_on_court; + } + const changed_match_tablet_operator = await match_utils.calc_match_set_player_on_tablet(match, match_on_court.setup); + if(changed_match_tablet_operator != null) { + match = changed_match_tablet_operator; + } + }); + + if(match.setup.now_on_court && !match.setup.called_timestamp) { + match.setup.called_timestamp = Date.now(); + } + + app.db.matches.insert(match, function(err) { + if (err) { + console.error(err); + } + admin.notify_change(app, tkey, 'match_add', { match }); + }); + }); + if(changes){ + setTimeout(function(){ + matches_incomplete.forEach(match => { + admin.notify_change(app, match.tournament_key, 'match_edit', { + match__id: match._id, + match: match + }); + }); + }, 500); + }; + + + callback(null); + }); +} + +async function reconcile_match_officials(app, tkey, callback) { + const admin = require('./admin'); + const stournament = require('./stournament'); + + app.db.tournaments.findOne({ key: tkey }, (tournamentErr, tournament) => { + if (tournamentErr) { + return callback(tournamentErr); + } + app.db.matches.find({ tournament_key: tkey }, (err, matches) => { + if (err) { + return callback(err); + } + + app.db.umpires.find({ tournament_key: tkey }, (err2, umpires) => { + if (err2) { + return callback(err2); + } + + const byId = new Map(); + const byBtpId = new Map(); + umpires.forEach((umpire) => { + byId.set(umpire._id, umpire); + if (umpire.btp_id != null) { + byBtpId.set(String(umpire.btp_id), umpire); + } + }); + + const refs = []; + matches.forEach((match) => { + const setup = match.setup || {}; + if (setup.umpire) { + refs.push({ official: setup.umpire, role: 'umpire', match }); + } + if (setup.service_judge) { + refs.push({ official: setup.service_judge, role: 'service_judge', match }); + } + }); + + let changed = false; + + async.eachSeries(refs, ({ official, role, match }, cb) => { + const existing = + (official._id && byId.get(official._id)) || + (official.btp_id != null && byBtpId.get(String(official.btp_id))) || + null; + + const is_on_court = match.setup && match.setup.now_on_court === true; + const is_finished = typeof match.team1_won === 'boolean' || match.btp_winner || match.btp_needsync; + const planned_key = role === 'umpire' ? 'is_planed_as_umpire' : 'is_planed_as_service_judge'; + const on_court_key = role === 'umpire' ? 'umpire_on_court' : 'service_judge_on_court'; + const safe_name = official.name || [official.firstname, official.surname].filter(Boolean).join(' ').trim(); + + if (!existing) { + const new_official = { + _id: official._id || (official.btp_id != null ? `${tkey}_btp_${official.btp_id}` : `${tkey}_${role}_${match._id}`), + tournament_key: tkey, + btp_id: official.btp_id, + firstname: official.firstname || '', + surname: official.surname || '', + name: safe_name, + country: official.country || '', + status: 'ready', + is_umpire: role === 'umpire', + is_service_judge: role === 'service_judge', + is_planed_as_umpire: role === 'umpire' && !is_on_court && !is_finished, + is_planed_as_service_judge: role === 'service_judge' && !is_on_court && !is_finished, + umpire_on_court: role === 'umpire' && is_on_court ? (match.setup.court_id || true) : null, + service_judge_on_court: role === 'service_judge' && is_on_court ? (match.setup.court_id || true) : null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: null, + checked_in: match_utils.get_effective_technical_official_checked_in({ + umpire_pause: null, + service_judge_pause: null, + umpire_manual_pause: null, + service_judge_manual_pause: null, + inactive_list: null, + }, tournament) + }; + changed = true; + app.db.umpires.insert(new_official, (insertErr, inserted) => { + if (insertErr) { + return cb(insertErr); + } + byId.set(inserted._id, inserted); + if (inserted.btp_id != null) { + byBtpId.set(String(inserted.btp_id), inserted); + } + admin.notify_change(app, tkey, 'umpire_add', { umpire: inserted }); + return cb(); + }); + return; + } + + const setObj = {}; + if ((existing.name || '') !== safe_name) setObj.name = safe_name; + if ((existing.firstname || '') !== (official.firstname || '')) setObj.firstname = official.firstname || ''; + if ((existing.surname || '') !== (official.surname || '')) setObj.surname = official.surname || ''; + if ((existing.country || '') !== (official.country || '')) setObj.country = official.country || ''; + if (official.btp_id != null && existing.btp_id !== official.btp_id) setObj.btp_id = official.btp_id; + + if (!is_finished) { + if (!is_on_court && existing[planned_key] !== true) { + setObj[planned_key] = true; + } + if (is_on_court && existing[on_court_key] == null) { + setObj[on_court_key] = match.setup.court_id || true; + } + if (existing.umpire_wait != null) { + setObj.umpire_wait = null; + } + if (existing.service_judge_wait != null) { + setObj.service_judge_wait = null; + } + if (existing.umpire_pause != null) { + setObj.umpire_pause = null; + } + if (existing.service_judge_pause != null) { + setObj.service_judge_pause = null; + } + if (existing.inactive_list != null) { + setObj.inactive_list = null; + } + } + + const next_checked_in = match_utils.get_effective_technical_official_checked_in({ ...existing, ...setObj }, tournament); + if (!!existing.checked_in !== next_checked_in) { + setObj.checked_in = next_checked_in; + } + + if (Object.keys(setObj).length === 0) { + return cb(); + } + + changed = true; + app.db.umpires.update( + { _id: existing._id }, + { $set: setObj }, + { returnUpdatedDocs: true }, + (updateErr, numAffected, updated) => { + if (updateErr) { + return cb(updateErr); + } + byId.set(updated._id, updated); + if (updated.btp_id != null) { + byBtpId.set(String(updated.btp_id), updated); + } + admin.notify_change(app, tkey, 'umpire_updated', updated); + return cb(); + } + ); + }, (seriesErr) => { + if (seriesErr || !changed) { + return callback(seriesErr); + } + stournament.get_umpires(app.db, tkey, (allErr, all_umpires) => { + if (!allErr) { + admin.notify_change(app, tkey, 'umpires_changed', { all_umpires }); + } + return callback(allErr); + }); + }); + }); + }); + }); +} + +function generateHallAbbreviation(name) { + const wordRegex = /([A-Za-zÄÖÜäöüß0-9]+)([\s\-]*)/g; + let match; + let abbreviation = ''; + let parts = []; + let foundAcronym = false; + + // Zerlege in Wortteile + Trennzeichen + while ((match = wordRegex.exec(name)) !== null) { + parts.push({ + word: match[1], + sep: match[2] || '' + }); + } + + let i = 0; + while (i < parts.length) { + const { word, sep } = parts[i]; + + // Zahlen mit optionalem Buchstaben (z.B. "12A") + if (/^\d+[A-Z]?$/.test(word)) { + abbreviation += sep + word; + + // Sonderregel: nächstes Wort beginnt mit Großbuchstabe → ersten Buchstaben übernehmen + if (i + 1 < parts.length && /^[A-ZÄÖÜ]/.test(parts[i + 1].word)) { + const next = parts[i + 1]; + abbreviation += next.sep + next.word[0].toUpperCase(); + i++; // zusätzliches Wort verarbeitet + } + + i++; + continue; + } + + // Großbuchstaben-Akronym + if (!foundAcronym && /^[A-ZÄÖÜ]{2,}$/.test(word)) { + abbreviation += word; + + if (i + 1 < parts.length) { + abbreviation += sep + parts[i + 1].word[0].toUpperCase(); + } else { + abbreviation += sep; + } + foundAcronym = true; + i += 2; + continue; + } + + // Standard: erster Buchstabe + if (!foundAcronym) { + abbreviation += word[0].toUpperCase() + sep; + } + + i++; + } + + // Kein Akronym → Leerzeichen & Endpunkt entfernen + if (!foundAcronym) { + abbreviation = abbreviation.replace(/\s+/g, ''); + abbreviation = abbreviation.replace(/\.+$/, ''); + } + + return abbreviation.trim(); +} + +function integrate_locations(app, tournament_key, btp_state, scoring_formats, callback) { + const admin = require('./admin'); // avoid dependency cycle + const stournament = require('./stournament'); // avoid dependency cycle + + const locations = Array.from(btp_state.locations.values()); + const res = new Map(); + var changed = false; + + async.eachSeries(locations, (l, cb) => { + const btp_id = l.ID[0]; + const name = l.Name[0]; + const address = (l.Address1 ? l.Address1[0] : ""); + const postal_code = (l.PostalCode ? l.PostalCode[0] : ""); + const city = (l.City ? l.City[0] : ""); + const state = (l.State ? l.State[0] : ""); + const country = (l.Country ? l.Country[0] : ""); + const preparation_addition = ""; + const meetingpoint_announcement = ""; + const short_name = generateHallAbbreviation(name); + + const query = { + tournament_key, + btp_id, + name, + address, + postal_code, + city, + state, + country, + short_name + }; + + app.db.locations.findOne(query, (err, cur_location) => { + if (err) return cb(err); + if (cur_location) { + res.set(btp_id, cur_location._id); + return cb(); + } + + const alt_query = { + tournament_key, + btp_id, + }; + + app.db.locations.findOne(alt_query, async (err, cur_location) => { + if (err) return cb(err); + + if (cur_location) { + + //ADD BTP ID + app.db.locations.update(alt_query, { $set: { btp_id, name, address, postal_code, city, state, country, preparation_addition, meetingpoint_announcement, short_name} }, {}, (err) => cb(err)); + return; + } + + const highlights = [0, 1, 2, 3, 4, 5, 6]; + let highlight = null; + + for (let i = highlights.length - 1; i >= 0; i--) { + const test = await app.db.locations.findOne_async({ tournament_key, highlight: highlights[i] }); + + if (!test) { + highlight = highlights[i]; + break; + } + } + + const location = { + _id: tournament_key + '_' + btp_id, + tournament_key, + btp_id, + name, + address, + postal_code, + city, + state, + country, + preparation_addition, + meetingpoint_announcement, + short_name, + highlight, + }; + + res.set(btp_id, location._id); + + changed = true; + app.db.locations.insert(location, (err) => cb(err)); + }); + }); + + }, (err) => { + if (err) { + return callback(err); + } + + if (changed) { + stournament.get_locations(app.db, tournament_key, function (err, all_locations) { + admin.notify_change(app, tournament_key, 'location_changed', { all_locations }); + callback(err, scoring_formats, res); + }); + } else { + callback(err, scoring_formats, res); + } + }); +} + + + + +// Returns a map btp_court_id => court._id +function integrate_courts(app, tournament_key, btp_state, scoring_formats, location_map, callback) { + const admin = require('./admin'); // avoid dependency cycle + const stournament = require('./stournament'); // avoid dependency cycle + + const courts = Array.from(btp_state.courts.values()); + const res = new Map(); + var changed = false; + async.each(courts, (c, cb) => { + const btp_id = c.ID[0]; + const name = c.Name[0]; + const btp_location_id = c.LocationID[0]; + const location_id = location_map.get(btp_location_id); + assert(location_id); + let num = parseInt(name, 10) || btp_id; + const m = /^Court\s*([0-9]+)$/.exec(name); + if (m) { + num = parseInt(m[1]); + } + const query = { + btp_id, + name, + num, + location_id, + tournament_key, + }; + + app.db.courts.findOne(query, async (err, cur_court) => { + if (err) return cb(err); + if (cur_court) { + res.set(btp_id, cur_court._id); + return cb(); + } + + const alt_query = { + tournament_key, + num, + }; + const court = { + _id: tournament_key + '_' + num, + tournament_key, + btp_id, + num, + name, + location_id, + is_active : true, + has_umpire: true, + has_service_judge: true, + }; + + res.set(btp_id, court._id); + app.db.courts.findOne(alt_query, (err, cur_court) => { + if (err) return cb(err); + + if (cur_court) { + // Add BTP ID + app.db.courts.update(alt_query, { $set: { btp_id, location_id } }, {}, (err) => cb(err)); + return; + } + + changed = true; + app.db.courts.insert(court, (err) => cb(err)); + }); + }); + }, (err) => { + if (err) { + return callback(err); + } + + if (changed) { + stournament.get_courts(app.db, tournament_key, function (err, all_courts) { + admin.notify_change(app, tournament_key, 'courts_changed', { all_courts }); + callback(err, scoring_formats, location_map, res); + }); + } else { + callback(err, scoring_formats, location_map, res); + } + }); +} + +function integrate_btp_settings(app, tkey, btp_state, callback) { + const admin = require('./admin'); // avoid dependency cycle + + app.db.tournaments.findOne({ key: tkey }, (err, tournament) => { + if (err) return callback(err); + var toChange = {}; + var changed = false; + if (!tournament.btp_settings) { + tournament.btp_settings = {}; + changed = true; + } + + const tournament_name = btp_state.btp_settings.get(1001).Value[0]; + const tournament_urn = btp_state.btp_settings.get(1008).Value[0]; + const check_in_per_match = btp_state.btp_settings.get(1003).Value[0] ? false : true; + const pause_duration_ms = btp_state.btp_settings.get(1303).Value[0] * 60 * 1000; + + if (tournament.btp_settings.tournament_name != tournament_name) { + tournament.btp_settings.tournament_name = tournament_name; + changed = true; + toChange.btp_settings = tournament.btp_settings; + toChange.name = tournament_name; + } + if (tournament.btp_settings.tournament_urn != tournament_urn) { + tournament.btp_settings.tournament_urn = tournament_urn; + changed = true; + toChange.btp_settings = tournament.btp_settings; + } + if (tournament.btp_settings.check_in_per_match != check_in_per_match) { + tournament.btp_settings.check_in_per_match = check_in_per_match; + changed = true; + toChange.btp_settings = tournament.btp_settings; + } + if (tournament.btp_settings.pause_duration_ms != pause_duration_ms) { + tournament.btp_settings.pause_duration_ms = pause_duration_ms; + changed = true; + toChange.btp_settings = tournament.btp_settings; + } + + if (changed) { + app.db.tournaments.update({ key: tkey }, { $set: toChange }, {}, (err) => { + if (err) { + return callback(err); + } + admin.notify_change(app, tkey, 'update_btp_settings', {btp_settings: toChange.btp_settings}); + return callback(null); + }); + } else { + return callback(null); + } + }); +} + +function buildScoringFormatMap(formats) { + const map = new Map(); + for (const f of formats) { + map.set(Number(f.id), f); + } + return map; +} - if (!bm.bts_complete) { - // TODO: register them as incomplete, but continue instead of returning - return; +function setTypeToEndMax(setType, score) { + const t = Number(setType); + switch (t) { + case 0: return { end_points: 21, max_points: 30, end_points_editable: false, max_points_editable: false }; + case 301: return { end_points: 11, max_points: 11, end_points_editable: false, max_points_editable: false }; + case 304: return { end_points: 11, max_points: 15, end_points_editable: false, max_points_editable: false }; + case 305: return { end_points: 11, max_points: 13, end_points_editable: false, max_points_editable: false }; + case 306: return { end_points: 15, max_points: 21, end_points_editable: false, max_points_editable: false }; + case 1000: + return { + end_points: null, + max_points: null, + end_points_editable: true, + max_points_editable: true, + }; + case 999: { + const s = Number(score); + const fallback = Number.isFinite(s) && s > 0 ? s : null; + return { + end_points: fallback, + max_points: fallback, + end_points_editable: false, + max_points_editable: true, + defaults_from_score: true, + }; + } + default: + return { + end_points: null, + max_points: null, + end_points_editable: false, + max_points_editable: false, + raw: t, + }; } +} - const gtid = event.GameTypeID[0]; - assert((gtid === 1) || (gtid === 2)); +function inferSetTiming(name, numSets, setType, isLastSet) { + const normalizedName = String(name || ''); + const t = Number(setType); + const singleSet = Number(numSets) === 1; + + function elevenSetTiming() { + if (singleSet || isLastSet) { + return { + interval_at: 6, + interval_duration_ms: normalizedName.includes('^90') ? 90000 : 60000, + }; + } + return { + interval_at: null, + interval_duration_ms: null, + }; + } - const scheduled_time_str = (bm.PlannedTime ? time_str(bm.PlannedTime[0]) : undefined); - const scheduled_date = (bm.PlannedTime ? date_str(bm.PlannedTime[0]) : undefined); - const match_name = bm.RoundName[0]; - const event_name = (event.Name[0] === draw.Name[0]) ? draw.Name[0] : event.Name[0] + ' - ' + draw.Name[0]; - const teams = _craft_teams(bm); + let timing; + switch (t) { + case 0: + timing = { + interval_at: 11, + interval_duration_ms: 60000, + }; + break; + case 301: + case 304: + case 305: + timing = elevenSetTiming(); + break; + case 306: + timing = { + interval_at: 8, + interval_duration_ms: 60000, + }; + break; + default: + timing = { + interval_at: null, + interval_duration_ms: null, + }; + break; + } - const btp_player_ids = []; - for (const team of bm.bts_players) { - for (const p of team) { - btp_player_ids.push(p.ID[0]); + let breakBeforeSetDurationMs = null; + if (!singleSet) { + if (normalizedName.includes('2x21+11') && isLastSet) { + breakBeforeSetDurationMs = 120000; + } else if (normalizedName.includes('^90')) { + breakBeforeSetDurationMs = 90000; + } else if ( + normalizedName.includes('~NLA') || + t === 0 || + t === 306 + ) { + breakBeforeSetDurationMs = 120000; + } else if (t === 301 || t === 304 || t === 305) { + breakBeforeSetDurationMs = 60000; } } - const setup = { - incomplete: !bm.bts_complete, - is_doubles: (gtid === 2), - match_num: bm.MatchNr[0], - counting: '3x21', - team_competition: false, - match_name, - event_name, - teams, + return { + ...timing, + break_before_set_duration_ms: breakBeforeSetDurationMs, }; - if (scheduled_time_str) { - setup.scheduled_time_str = scheduled_time_str; - } - if (scheduled_date) { - setup.scheduled_date = scheduled_date; - } - if (bm.CourtID) { - const btp_court_id = bm.CourtID[0]; - const court_id = court_map.get(btp_court_id); - assert(court_id); - setup.court_id = court_id; - setup.now_on_court = match_ids_on_court.has(bm.ID[0]); - } - if (bm.Official1ID) { - const o = officials.get(bm.Official1ID[0]); - assert(o); - setup.umpire_name = o.FirstName + ' ' + o.Name; - } - if (bm.Official2ID) { - const o = officials.get(bm.Official2ID[0]); - assert(o); - setup.service_judge_name = o.FirstName + ' ' + o.Name; - } - - const btp_match_ids = [{ - id: bm.ID[0], - nr: bm.MatchNr[0], - draw: bm.DrawID[0], - planning: bm.PlanningID[0], - }]; - - const match = { - tournament_key: tkey, - btp_id, - btp_match_ids, - btp_player_ids, - setup, +} + +function applyDefaultSetTiming(setPoints) { + const merged = { + ...setPoints, + }; + const endPoints = Number(merged.end_points); + if (merged.interval_at == null && Number.isFinite(endPoints) && endPoints > 0) { + merged.interval_at = Math.ceil(endPoints / 2); + } + if (merged.interval_duration_ms == null) { + merged.interval_duration_ms = 60000; + } + if (merged.break_before_set_duration_ms == null) { + merged.break_before_set_duration_ms = 120000; + } + return merged; +} + +function sanitizeSetPoints(setPoints) { + const merged = applyDefaultSetTiming(setPoints); + let endPoints = Number(merged.end_points); + if (!Number.isFinite(endPoints) || endPoints < 1) { + endPoints = 1; + } + let maxPoints = Number(merged.max_points); + if (!Number.isFinite(maxPoints) || maxPoints < endPoints) { + maxPoints = endPoints; + } + merged.end_points = endPoints; + merged.max_points = maxPoints; + if (merged.interval_at == null) { + merged.interval_at = Math.ceil(endPoints / 2); + } + return merged; +} + +function sanitizeScoringFormat(scoringFormat) { + if (!scoringFormat) { + return scoringFormat; + } + return { + ...scoringFormat, + set_points: sanitizeSetPoints(scoringFormat.set_points || {}), + last_set_points: sanitizeSetPoints(scoringFormat.last_set_points || {}), + }; +} + +function normalizeScoringFormat(sf, unwrap = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v)) { + const id = Number(unwrap(sf.ID)); + const name = String(unwrap(sf.Name)); + const numSets = Number(unwrap(sf.NumSets)); + const setType = Number(unwrap(sf.SetType)); + const lastSetType = Number(unwrap(sf.LastSetType)); + const score = Number(unwrap(sf.Score)); + const isDefault = Boolean(unwrap(sf.IsDefault)); + + return sanitizeScoringFormat({ + id, + name, + numSets, + score, + isDefault, + setType, + lastSetType, + set_points: applyDefaultSetTiming({ + ...setTypeToEndMax(setType, score), + ...inferSetTiming(name, numSets, setType, false), + }), + last_set_points: applyDefaultSetTiming({ + ...setTypeToEndMax(lastSetType, score), + ...inferSetTiming(name, numSets, lastSetType, true), + }), + }); +} + +function fallbackScoringFormat() { + const scoringFormat = normalizeScoringFormat({ + ID: [0], + Name: ['3x21'], + NumSets: ['3'], + SetType: ['0'], + LastSetType: ['0'], + Score: ['21'], + IsDefault: [false], + }); + scoringFormat.id = null; + return scoringFormat; +} + +function mergeLocalSetPoints(existingSetPoints, normalizedSetPoints) { + const merged = { + ...normalizedSetPoints, }; - match.team1_won = undefined; - match.btp_winner = undefined; - if (bm.Winner) { - match.btp_winner = bm.Winner[0]; - match.team1_won = (match.btp_winner === 1); + if (!existingSetPoints) { + return merged; + } + + if (normalizedSetPoints.end_points_editable) { + merged.end_points = existingSetPoints.end_points ?? merged.end_points; + } + if (normalizedSetPoints.max_points_editable) { + merged.max_points = existingSetPoints.max_points ?? merged.max_points; + } + + merged.interval_at = existingSetPoints.interval_at ?? merged.interval_at; + merged.interval_duration_ms = existingSetPoints.interval_duration_ms ?? merged.interval_duration_ms; + merged.break_before_set_duration_ms = existingSetPoints.break_before_set_duration_ms ?? merged.break_before_set_duration_ms; + if (existingSetPoints.interval_enabled !== undefined) { + merged.interval_enabled = existingSetPoints.interval_enabled; + } + + return merged; +} + +function mergeLocalScoringFormat(existingFormat, normalizedFormat) { + if (!existingFormat) { + return sanitizeScoringFormat(normalizedFormat); + } + return sanitizeScoringFormat({ + ...normalizedFormat, + set_points: mergeLocalSetPoints(existingFormat.set_points, normalizedFormat.set_points), + last_set_points: mergeLocalSetPoints(existingFormat.last_set_points, normalizedFormat.last_set_points), + }); +} + +function integrate_btp_scoring_formats(app, tkey, btp_state, callback) { + const admin = require("./admin"); // avoid dependency cycle + + const unwrap = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v); + + const deepEqualJson = (a, b) => JSON.stringify(a) === JSON.stringify(b); + + app.db.tournaments.findOne({ key: tkey }, (err, tournament) => { + if (err) return callback(err); + if (!tournament) return callback(new Error(`Tournament not found for key: ${tkey}`)); + + if (!tournament.scoring_formats) tournament.scoring_formats = {}; + + if (!btp_state?.scoring_formats || !(btp_state.scoring_formats instanceof Map)) { + return callback(new Error("btp_state.scoring_formats is missing or not a Map")); + } + + const existingFormatsById = new Map( + (((tournament.scoring_formats || {}).formats) || []).map(f => [Number(f.id), f]) + ); + + const formats = Array.from(btp_state.scoring_formats.values()) + .map(sf => normalizeScoringFormat(sf, unwrap)) + .map(sf => mergeLocalScoringFormat(existingFormatsById.get(Number(sf.id)), sf)) + .sort((a, b) => a.id - b.id); + + const defaultFormat = formats.find(f => f.isDefault) || null; + + const scoringFormatsPayload = { + formats, + default_id: defaultFormat ? defaultFormat.id : null, + }; + + const scoringFormatMap = buildScoringFormatMap(formats); + + const existing = tournament.scoring_formats || null; + + // No change + if (deepEqualJson(existing, scoringFormatsPayload)) { + return callback(null, scoringFormatMap); + } + + tournament.scoring_formats = scoringFormatsPayload; + + app.db.tournaments.update( + { key: tkey }, + { $set: { scoring_formats: tournament.scoring_formats } }, + {}, + (err) => { + if (err) return callback(err); + + admin.notify_change(app, tkey, "update_btp_scoring_formats", { + scoring_formats: scoringFormatsPayload, + }); + + return callback(null, scoringFormatMap); + } + ); + }); +} + +function integrate_events(app, tkey, btp_state, callback) { + const admin = require("./admin"); // avoid dependency cycle + + const unwrap = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v); + const deepEqualJson = (a, b) => JSON.stringify(a) === JSON.stringify(b); + + if (!btp_state || !(btp_state.events instanceof Map) || !(btp_state.stages instanceof Map)) { + return callback(new Error("btp_state.events/stages missing or not a Map")); + } + + function normalizeEvent(ev) { + return { + id: Number(unwrap(ev.ID)), + name: String(unwrap(ev.Name)), + game_type_id: Number(unwrap(ev.GameTypeID)), + gender_id: Number(unwrap(ev.GenderID)), + min_age: Number(unwrap(ev.MinAge)), + max_age: Number(unwrap(ev.MaxAge)), + fee: Number(unwrap(ev.Fee)), + separate_seeding: Boolean(unwrap(ev.SeparateSeeding)), + allow_online_entry: Boolean(unwrap(ev.AllowOnlineEntry)), + grading_id: Number(unwrap(ev.GradingID)), + sub_grading_id: Number(unwrap(ev.SubGradingID)), + sub_grading2_id: Number(unwrap(ev.SubGrading2ID)), + }; } - if (bm.Sets) { - match.network_score = _parse_score(bm); + + function normalizeStage(st) { + return { + id: Number(unwrap(st.ID)), + name: String(unwrap(st.Name)), + event_id: Number(unwrap(st.EventID)), + stage_type: Number(unwrap(st.StageType)), + display_order: Number(unwrap(st.DisplayOrder)), + scoring_format: st.ScoringFormat !== undefined ? Number(unwrap(st.ScoringFormat)) : null, + }; } - if (bm.Shuttles) { - match.shuttle_count = bm.Shuttles[0]; + + // Build normalized payload: + // events: [{... , stages:[...]}] for convenient GUI + lookups + const eventsArr = Array.from(btp_state.events.values()) + .map(normalizeEvent) + .sort((a, b) => a.id - b.id); + + const stagesArr = Array.from(btp_state.stages.values()) + .map(normalizeStage) + .sort((a, b) => a.id - b.id); + + const stagesByEventId = new Map(); + for (const st of stagesArr) { + if (!stagesByEventId.has(st.event_id)) stagesByEventId.set(st.event_id, []); + stagesByEventId.get(st.event_id).push(st); } - if (bm.DisplayOrder) { - match.match_order = bm.DisplayOrder[0]; + + // Keep stages sorted by display_order then id for stability + for (const [eventId, list] of stagesByEventId.entries()) { + list.sort((a, b) => (a.display_order - b.display_order) || (a.id - b.id)); } - match._id = 'btp_' + btp_id; - return match; -} + const payload = { + events: eventsArr.map((ev) => ({ + ...ev, + stages: stagesByEventId.get(ev.id) || [], + })), + // optional: keep a flat list too, if you prefer later + // stages: stagesArr, + }; -function _craft_team(par) { - if (!par) { - return {players: []}; - } + app.db.tournaments.findOne({ key: tkey }, (err, tournament) => { + if (err) return callback(err); + if (!tournament) return callback(new Error(`Tournament not found for key: ${tkey}`)); - const players = par.map(p => { - const asian_name = !! (p.Asianname && p.Asianname[0]); - const pres = {asian_name}; - if (p.Firstname && p.Lastname) { - if (asian_name) { - pres.name = p.Lastname[0].toUpperCase() + ' ' + p.Firstname[0]; - } else { - pres.name = p.Firstname[0] + ' ' + p.Lastname[0]; - } + if (!tournament.events) tournament.events = {}; - pres.firstname = p.Firstname[0]; - pres.lastname = p.Lastname[0]; - } else if (p.Lastname) { - pres.name = p.Lastname[0]; - pres.lastname = p.Lastname[0]; - pres.firstname = ''; - } else if (p.Firstname) { - pres.name = p.Firstname[0]; - pres.lastname = p.Firstname[0]; - pres.firstname = ''; + const existing = tournament.events || null; + if (deepEqualJson(existing, payload)) { + return callback(null); } - if (p.Country && p.Country[0]) { - pres.nationality = p.Country[0]; - } - fix_player(pres); - return pres; - }); + tournament.events = payload; - const tres = { - players, - }; + const toChange = { events: tournament.events }; - if ((players.length === 2) && (players[0].nationality != players[1].nationality)) { - tres.name = countries.lookup(players[0].nationality) + ' / ' + countries.lookup(players[1].nationality); - } else if ((players.length > 0) && (players[0].nationality)) { - tres.name = countries.lookup(players[0].nationality); - } + app.db.tournaments.update({ key: tkey }, { $set: toChange }, {}, (err) => { + if (err) return callback(err); - return tres; -} + admin.notify_change(app, tkey, "update_btp_events", { + events: payload, + }); -function _craft_teams(bm) { - assert(bm.bts_players); - return bm.bts_players.map(_craft_team); + return callback(null); + }); + }); } -function _parse_score(bm) { - assert(bm.Sets); - assert(bm.Sets[0]); - assert(bm.Sets[0].Set); - return bm.Sets[0].Set.map(s => [s.T1[0], s.T2[0]]); -} +async function integrate_player_state(app, tkey, btp_state, callback) { + const btp_manager = require('./btp_manager'); + app.db.tournaments.findOne({ key: tkey }, (err, tournament) => { + if (err) return callback(err); -function integrate_matches(app, tkey, btp_state, court_map, callback) { - const admin = require('./admin'); // avoid dependency cycle - const {draws, events, officials} = btp_state; + if (!tournament.btp_settings.check_in_per_match) { + let ids_to_change = []; + let players_to_change = []; + async.eachOfSeries(btp_state.matches, async (match, key) => { + let cur_match = await get_match_form_db(app, tkey, btp_state, match); + if (cur_match && cur_match != null) { + for (let team_nr = 0; team_nr < cur_match.setup.teams.length; team_nr++) { + for (let player_nr = 0; player_nr < cur_match.setup.teams[team_nr].players.length; player_nr++) { + let id = pause_is_done(match, team_nr, player_nr, tournament.btp_settings); - const match_ids_on_court = calculate_match_ids_on_court(btp_state); + if (id != undefined && id != null) { - async.each(btp_state.matches, function(bm, cb) { - const draw = draws.get(bm.DrawID[0]); - assert(draw); + if (!cur_match.setup.teams[team_nr].players[player_nr].now_tablet_on_court && + !cur_match.setup.teams[team_nr].players[player_nr].now_playing_on_court && + !cur_match.setup.called_timestamp && + !cur_match.network_score) { - const event = events.get(draw.EventID[0]); - assert(event); + btp_state.matches[key].bts_players[team_nr][player_nr].CheckedIn[0] = true; + + + const player = cur_match.setup.teams[team_nr].players[player_nr]; + if (ids_to_change.indexOf(id) == -1) { + player.checked_in = true; + player.check_in_per_match = false; + player.tablet_break_active = false; + ids_to_change.push(id); + players_to_change.push(player); + } + } + } + } + } + } + }, (err) => { + if (err) return callback(err); + btp_manager.update_players(app, tkey, players_to_change); + return callback(null); + }); + } + else { + return callback(null); + } + + }); +} - const discipline_name = (event.Name[0] === draw.Name[0]) ? draw.Name[0] : event.Name[0] + '_' + draw.Name[0]; - const btp_id = tkey + '_' + discipline_name + '_' + bm.ID[0]; +async function get_match_form_db(app, tkey, btp_state, match) { + return new Promise((resolve, reject) => { + const { draws, events } = btp_state; + const btp_id = calculate_btp_match_id(tkey, match, draws, events); const query = { - btp_id, + btp_id: btp_id, tournament_key: tkey, }; - // TODO get all matches upfront here + app.db.matches.findOne(query, (err, cur_match) => { - if (err) return cb(err); + if (err) { + console.log(err); + return reject(err); + }; - if (cur_match && cur_match.btp_needsync) { - cb(); - return; + if (cur_match) { + return resolve(cur_match); + } else { + return resolve(null); } + }); + }); +} + +function pause_is_done(match, team_nr, player_nr, btp_settings) { + if (match.bts_players && match.bts_players.length > team_nr) { + if (match.bts_players[team_nr] && match.bts_players[team_nr].length > player_nr) { + const player = match.bts_players[team_nr][player_nr]; - const match = craft_match(tkey, btp_id, court_map, event, draw, officials, bm, match_ids_on_court); - if (!match) { - cb(); + if (player.CheckedIn[0]) { return; } - if (cur_match) { - if (utils.plucked_deep_equal(match, cur_match, Object.keys(match), true)) { - // No update required - cb(); - return; - } - - app.db.matches.update({_id: cur_match._id}, {$set: match}, {}, (err) => { - if (err) return cb(err); + if (player.LastTimeOnCourt && player.LastTimeOnCourt[0]) { + const date = new Date(player.LastTimeOnCourt[0].year, + player.LastTimeOnCourt[0].month - 1, + player.LastTimeOnCourt[0].day, + player.LastTimeOnCourt[0].hour, + player.LastTimeOnCourt[0].minute, + player.LastTimeOnCourt[0].second, + player.LastTimeOnCourt[0].ms); + const last_time_on_court_ts = date.getTime(); + const now = new Date(); - admin.notify_change(app, match.tournament_key, 'match_edit', {match__id: match._id, setup: match.setup}); - cb(); - }); + if ((now - last_time_on_court_ts) > btp_settings.pause_duration_ms) { + return player.ID[0]; + } return; + } else { + return player.ID[0]; } + } + } + return; +} - app.db.matches.insert(match, function(err) { - if (err) return cb(err); +function buildOfficialReferenceState(matches) { + const referenced_ids = new Set(); + const referenced_btp_ids = new Set(); + const planned_umpire_ids = new Set(); + const planned_umpire_btp_ids = new Set(); + const planned_service_judge_ids = new Set(); + const planned_service_judge_btp_ids = new Set(); + const on_court_umpire_ids = new Set(); + const on_court_umpire_btp_ids = new Set(); + const on_court_service_judge_ids = new Set(); + const on_court_service_judge_btp_ids = new Set(); - admin.notify_change(app, tkey, 'match_add', {match}); - cb(); - }); - }); - }, callback); -} + (matches || []).forEach((match) => { + const setup = match.setup || {}; + const is_finished = typeof match.team1_won === 'boolean' || match.btp_winner || match.btp_needsync; + const is_on_court = setup.now_on_court === true; + const is_planned = !is_finished && !is_on_court && !!setup.state; -// Returns a map btp_court_id => court._id -function integrate_courts(app, tournament_key, btp_state, callback) { - const admin = require('./admin'); // avoid dependency cycle - const stournament = require('./stournament'); // avoid dependency cycle + const addRef = (official, idSet, btpSet) => { + if (!official) return; + if (official._id) idSet.add(String(official._id)); + if (official.btp_id != null) btpSet.add(String(official.btp_id)); + }; - const courts = Array.from(btp_state.courts.values()); - const res = new Map(); - var changed = false; + if (!is_finished) { + [setup.umpire, setup.service_judge].forEach((official) => { + addRef(official, referenced_ids, referenced_btp_ids); + }); + } - async.each(courts, (c, cb) => { - const btp_id = c.ID[0]; - const name = c.Name[0]; - let num = parseInt(name, 10) || btp_id; - const m = /^Court\s*([0-9]+)$/.exec(name); - if (m) { - num = parseInt(m[1]); + if (is_on_court) { + addRef(setup.umpire, on_court_umpire_ids, on_court_umpire_btp_ids); + addRef(setup.service_judge, on_court_service_judge_ids, on_court_service_judge_btp_ids); + } else if (is_planned) { + addRef(setup.umpire, planned_umpire_ids, planned_umpire_btp_ids); + addRef(setup.service_judge, planned_service_judge_ids, planned_service_judge_btp_ids); } - const query = { - btp_id, - name, - num, - tournament_key, - }; + }); - app.db.courts.findOne(query, (err, cur_court) => { - if (err) return cb(err); - if (cur_court) { - res.set(btp_id, cur_court._id); - return cb(); - } + return { + referenced_ids, + referenced_btp_ids, + planned_umpire_ids, + planned_umpire_btp_ids, + planned_service_judge_ids, + planned_service_judge_btp_ids, + on_court_umpire_ids, + on_court_umpire_btp_ids, + on_court_service_judge_ids, + on_court_service_judge_btp_ids, + }; +} - const alt_query = { - tournament_key, - num, - }; - const court = { - _id: tournament_key + '_' + num, - tournament_key, - btp_id, - num, - name, - }; - res.set(btp_id, court._id); - app.db.courts.findOne(alt_query, (err, cur_court) => { - if (err) return cb(err); +function computeOfficialVisibilityPatch(official, refState, tournament = null) { + const hasId = (set) => set.has(String(official._id)); + const hasBtpId = (set) => official.btp_id != null && set.has(String(official.btp_id)); + const inSet = (ids, btpIds) => hasId(ids) || hasBtpId(btpIds); - if (cur_court) { - // Add BTP ID - app.db.courts.update(alt_query, {$set: {btp_id}}, {}, (err) => cb(err)); - return; - } + const in_active_list = + official.umpire_wait != null || + official.service_judge_wait != null || + official.umpire_pause != null || + official.service_judge_pause != null || + official.umpire_manual_pause != null || + official.service_judge_manual_pause != null; + const referenced_somewhere = inSet(refState.referenced_ids, refState.referenced_btp_ids); + const should_be_planned_as_umpire = inSet(refState.planned_umpire_ids, refState.planned_umpire_btp_ids); + const should_be_planned_as_service_judge = inSet(refState.planned_service_judge_ids, refState.planned_service_judge_btp_ids); + const should_be_umpire_on_court = inSet(refState.on_court_umpire_ids, refState.on_court_umpire_btp_ids); + const should_be_service_judge_on_court = inSet(refState.on_court_service_judge_ids, refState.on_court_service_judge_btp_ids); - changed = true; - app.db.courts.insert(court, (err) => cb(err)); - }); - }); - }, (err) => { - if (err) return callback(err); + const setObj = {}; + if (!!official.is_planed_as_umpire !== should_be_planned_as_umpire) { + setObj.is_planed_as_umpire = should_be_planned_as_umpire; + } + if (!!official.is_planed_as_service_judge !== should_be_planned_as_service_judge) { + setObj.is_planed_as_service_judge = should_be_planned_as_service_judge; + } + if ((official.umpire_on_court != null) !== should_be_umpire_on_court) { + setObj.umpire_on_court = should_be_umpire_on_court ? (official.umpire_on_court || true) : null; + } + if ((official.service_judge_on_court != null) !== should_be_service_judge_on_court) { + setObj.service_judge_on_court = should_be_service_judge_on_court ? (official.service_judge_on_court || true) : null; + } - if (changed) { - stournament.get_courts(app.db, tournament_key, function(err, all_courts) { - admin.notify_change(app, tournament_key, 'courts_changed', {all_courts}); - callback(err, res); - }); + const on_court = should_be_umpire_on_court || should_be_service_judge_on_court; + const visible_somewhere = in_active_list || on_court || referenced_somewhere; + if (!visible_somewhere) { + const now = Date.now(); + const reactivated_wait_ts = Math.floor(now / 10); + let preferred_role = null; + if (official.umpire_wait != null || official.umpire_pause != null || official.umpire_manual_pause != null || official.is_planed_as_umpire || official.umpire_on_court != null) { + preferred_role = 'umpire'; + } else if (official.service_judge_wait != null || official.service_judge_pause != null || official.service_judge_manual_pause != null || official.is_planed_as_service_judge || official.service_judge_on_court != null) { + preferred_role = 'service_judge'; + } else if (official.is_umpire === true && official.is_service_judge !== true) { + preferred_role = 'umpire'; + } else if (official.is_service_judge === true && official.is_umpire !== true) { + preferred_role = 'service_judge'; + } else if (official.is_umpire === true && official.is_service_judge === true) { + preferred_role = 'umpire'; + } + + if (preferred_role === 'umpire') { + setObj.umpire_wait = official.umpire_wait != null ? official.umpire_wait : reactivated_wait_ts; + setObj.service_judge_wait = null; + } else if (preferred_role === 'service_judge') { + setObj.service_judge_wait = official.service_judge_wait != null ? official.service_judge_wait : reactivated_wait_ts; + setObj.umpire_wait = null; + } + if (official.is_umpire !== true && official.is_service_judge !== true && official.inactive_list == null) { + setObj.inactive_list = now; } else { - callback(err, res); + setObj.inactive_list = null; } - }); + if (setObj.is_planed_as_umpire === undefined) setObj.is_planed_as_umpire = false; + if (setObj.is_planed_as_service_judge === undefined) setObj.is_planed_as_service_judge = false; + if (setObj.umpire_on_court === undefined) setObj.umpire_on_court = null; + if (setObj.service_judge_on_court === undefined) setObj.service_judge_on_court = null; + } + + const next_checked_in = match_utils.get_effective_technical_official_checked_in({ ...official, ...setObj }, tournament); + if (!!official.checked_in !== next_checked_in) { + setObj.checked_in = next_checked_in; + } + + return setObj; +} + +function findExistingOfficialForBtpImport(officials, tournament_key, btp_id) { + const canonical_id = `${tournament_key}_btp_${btp_id}`; + return (officials || []).find((official) => + official && + official.tournament_key === tournament_key && + ( + (official.btp_id != null && String(official.btp_id) === String(btp_id)) || + String(official._id) === canonical_id + ) + ) || null; } function integrate_umpires(app, tournament_key, btp_state, callback) { @@ -310,39 +2338,101 @@ function integrate_umpires(app, tournament_key, btp_state, callback) { const officials = Array.from(btp_state.officials.values()); var changed = false; + app.db.umpires.find({ tournament_key }, (err, existingOfficials) => { + if (err) return callback(err); async.each(officials, (o, cb) => { - const name = (o.FirstName ? (o.FirstName[0] + ' ') : '') + ((o.Name && o.Name[0]) ? o.Name[0] : ''); - if (!name) { + const firstname = (o.FirstName ? o.FirstName[0] : ''); + const surname = (o.Name ? o.Name[0] : ''); + const name = (firstname + " " + surname).trim(); + const country = (o.Country ? o.Country[0] : ''); + const btp_id = o.ID[0]; + if (!btp_id) { return cb(); } - const btp_id = o.ID[0]; + + + const cur = findExistingOfficialForBtpImport(existingOfficials, tournament_key, btp_id); + - app.db.umpires.findOne({tournament_key, name}, (err, cur) => { - if (err) return cb(err); if (cur) { - if (cur.btp_id === btp_id) { + + const allListsNull = !cur.is_planed_as_umpire && + !cur.is_planed_as_service_judge && + cur.umpire_on_court == null && + cur.service_judge_on_court == null && + cur.umpire_wait == null && + cur.service_judge_wait == null && + cur.umpire_pause == null && + cur.service_judge_pause == null && + cur.umpire_manual_pause == null && + cur.service_judge_manual_pause == null && + cur.inactive_list == null; + + + if (cur.btp_id === btp_id && + cur.firstname == firstname && + cur.surname == surname && + cur.country === country && + !allListsNull) { return cb(); } else { - app.db.umpires.update({tournament_key, name}, {$set: {btp_id}}, {}, (err) => cb(err)); - return; + const inactive_list = allListsNull ? Date.now() : null; + app.db.umpires.update({ _id: cur._id, tournament_key }, { $set: { btp_id, firstname, surname, name, country, inactive_list} }, { returnUpdatedDocs: true }, function (err, numAffected, changed_umpire) { + if (err) { + console.error(err); + return cb(err); + } + const idx = existingOfficials.findIndex((official) => official && official._id === changed_umpire._id); + if (idx >= 0) { + existingOfficials[idx] = changed_umpire; + } else { + existingOfficials.push(changed_umpire); + } + const admin = require('./admin'); + admin.notify_change(app, tournament_key, 'umpire_updated', changed_umpire); + }); + return cb(); } } const u = { _id: tournament_key + '_btp_' + btp_id, btp_id, + firstname, + surname, name, + status: 'ready', tournament_key, + country, + is_umpire: true, + is_service_judge: true, + is_planed_as_umpire: false, + is_planed_as_service_judge: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + umpire_manual_pause: null, + service_judge_manual_pause: null, + inactive_list: Date.now() }; changed = true; - app.db.umpires.insert(u, err => cb(err)); - }); + app.db.umpires.insert(u, function (err, inserted_umpire) { + if (err) { + return cb(err); + } + existingOfficials.push(inserted_umpire); + admin.notify_change(app, tournament_key, 'umpire_add', { umpire: inserted_umpire }); + return cb(); + }); }, err => { if (changed) { - stournament.get_umpires(app.db, tournament_key, function(err, all_umpires) { + stournament.get_umpires(app.db, tournament_key, function (err, all_umpires) { if (!err) { - admin.notify_change(app, tournament_key, 'umpires_changed', {all_umpires}); + admin.notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); } callback(err); }); @@ -350,6 +2440,7 @@ function integrate_umpires(app, tournament_key, btp_state, callback) { callback(err); } }); + }); } function calculate_match_ids_on_court(btp_state) { @@ -364,61 +2455,269 @@ function calculate_match_ids_on_court(btp_state) { return res; } -function integrate_now_on_court(app, tkey, callback) { + + +function update_umpire(app, tkey, umpire, status, last_time_on_court_ts,court_id) { + app.db.umpires.update({ tournament_key: tkey, name: umpire.name }, { $set: { last_time_on_court_ts: last_time_on_court_ts, status: status, court_id: court_id } }, { returnUpdatedDocs: true }, function (err, numAffected, changed_umpire) { + if (err) { + console.error(err); + return; + } + const admin = require('./admin'); + admin.notify_change(app, tkey, 'umpire_updated', changed_umpire); + }); +} + +function normalize_official_visibility(app, tournament_key, callback) { + const admin = require('./admin'); + const stournament = require('./stournament'); + + app.db.tournaments.findOne({ key: tournament_key }, (tournamentErr, tournament) => { + if (tournamentErr) { + return callback(tournamentErr); + } + app.db.matches.find({ tournament_key }, (matchErr, matches) => { + if (matchErr) { + return callback(matchErr); + } + + const refState = buildOfficialReferenceState(matches); + + app.db.umpires.find({ tournament_key }, (err, officials) => { + if (err) { + return callback(err); + } + + let changed = false; + async.eachSeries(officials, (official, cb) => { + const setObj = computeOfficialVisibilityPatch(official, refState, tournament); + if (Object.keys(setObj).length === 0) { + return cb(); + } + + app.db.umpires.update( + { _id: official._id, tournament_key }, + { $set: setObj }, + { returnUpdatedDocs: true }, + (updateErr, numAffected, changed_umpire) => { + if (updateErr) { + return cb(updateErr); + } + if (changed_umpire) { + changed = true; + admin.notify_change(app, tournament_key, 'umpire_updated', changed_umpire); + } + cb(); + } + ); + }, (eachErr) => { + if (eachErr) { + return callback(eachErr); + } + if (!changed) { + return callback(null); + } + stournament.get_umpires(app.db, tournament_key, function (getErr, all_umpires) { + if (!getErr) { + admin.notify_change(app, tournament_key, 'umpires_changed', { all_umpires }); + } + callback(getErr); + }); + }); + }); + }); + }); +} + +async function integrate_now_on_court(app, tkey, callback) { + const admin = require('./admin'); // avoid dependency cycle + const btp_manager = require('./btp_manager'); + const bupws = require('./bupws'); + const match_utils = require('./match_utils'); + + function matchHasPlayerOnCourtFlags(match) { + if (!match || !match.setup || !match.setup.teams) { + return false; + } + return match.setup.teams.some(team => + team.players && team.players.some(player => player.now_playing_on_court || player.now_tablet_on_court) + ); + } + + function collectActivePlayerIds(matches) { + const activeIds = new Set(); + matches.forEach(match => { + if (!match || !match.setup || !match.setup.teams) { + return; + } + match.setup.teams.forEach(team => { + if (!team.players) { + return; + } + team.players.forEach(player => { + if (player && player.btp_id) { + activeIds.add(player.btp_id); + } + }); + }); + if (match.setup.tabletoperators) { + match.setup.tabletoperators.forEach(player => { + if (player && player.btp_id) { + activeIds.add(player.btp_id); + } + }); + } + }); + return activeIds; + } + + function matchHasOnlyStalePlayerFlags(match, activePlayerIds) { + if (!match || !match.setup || !match.setup.teams) { + return false; + } + return match.setup.teams.some(team => + team.players && team.players.some(player => + (player.now_playing_on_court || player.now_tablet_on_court) && + (!player.btp_id || !activePlayerIds.has(player.btp_id)) + ) + ); + } + + function setPlayerStateForMatch(match) { + return new Promise((resolve, reject) => { + match_utils.set_player_on_court(app, tkey, match.setup, (err) => { + if (err) return reject(err); + match_utils.set_player_on_tablet(app, tkey, match.setup, (err) => { + if (err) return reject(err); + resolve(null); + }); + }); + }); + } + + function clearPlayerStateForMatch(match) { + const endTs = match.end_ts || Date.now(); + return new Promise((resolve, reject) => { + match_utils.remove_player_on_court(app, tkey, match._id, endTs, (err) => { + if (err) return reject(err); + match_utils.remove_tablet_on_court(app, tkey, match._id, endTs, (err) => { + if (err) return reject(err); + resolve(null); + }); + }); + }); + } + // TODO after switching to async, this should happen during court&match construction - app.db.tournaments.findOne({key: tkey}, (err, tournament) => { + app.db.tournaments.findOne({ key: tkey }, async (err, tournament) => { if (err) { return callback(err); } assert(tournament); - if (!tournament.only_now_on_court) { - return; // Nothing to do here - } - - app.db.matches.find({'setup.now_on_court': true}, (err, now_on_court_matches) => { + + app.db.matches.find({ tournament_key: tkey, 'setup.now_on_court': true }, async (err, now_on_court_matches) => { if (err) return callback(err); - async.each(now_on_court_matches, (match, cb) => { + const activeMatches = now_on_court_matches.filter(match => typeof match.team1_won !== 'boolean'); + await Promise.all(activeMatches.map(async (match) => { + const court_id = match.setup.court_id; const match_id = match._id; - if (!court_id || !match_id) return cb(); // TODO in async we would assert both to be true + + if (!court_id || !match_id) { + return; // TODO in async we would assert both to be true + } + + const setup = match.setup; + if(!setup.called_timestamp) { + match_utils.call_match(app, tournament, match, undefined, (err) => { + if (err) console.log(err); + }); + } else { + const query = { + tournament_key: tkey, + _id: court_id, + }; + app.db.courts.update(query, {$set: {match_id}}); + } + await setPlayerStateForMatch(match); + })); + + app.db.matches.find({ tournament_key: tkey }, async (err, matches) => { + if (err) return callback(err); - const court_q = {_id: court_id}; - app.db.courts.find(court_q, (err, courts) => { - if (err) return cb(err); - if (courts.length !== 1) return cb(); - const [court] = courts; + app.db.matches.find({ tournament_key: tkey, 'setup.now_on_court': true }, async (err, refreshed_on_court_matches) => { + if (err) return callback(err); - app.db.courts.update(court_q, {$set: {match_id}}, {}, (err) => cb(err)); + const refreshedActiveMatches = refreshed_on_court_matches.filter(match => typeof match.team1_won !== 'boolean'); + const activePlayerIds = collectActivePlayerIds(refreshedActiveMatches); + const staleMatches = matches.filter(match => + match && + match.setup && + match.setup.now_on_court !== true && + matchHasPlayerOnCourtFlags(match) && + matchHasOnlyStalePlayerFlags(match, activePlayerIds) + ); + + await Promise.all(staleMatches.map(match => clearPlayerStateForMatch(match))); + callback(null); + }); }); - }, callback); + }); }); - }); // TODO clear courts (better in async) } -function fetch(app, tkey, response, callback) { - let btp_state; - try { - btp_state = btp_parse.get_btp_state(response); - } catch (e) { - return callback(e); - } - async.waterfall([ - cb => integrate_umpires(app, tkey, btp_state, cb), - cb => integrate_courts(app, tkey, btp_state, cb), - (court_map, cb) => integrate_matches(app, tkey, btp_state, court_map, cb), - cb => integrate_now_on_court(app, tkey, cb), - ], callback); +async function sync_btp_data(app, tkey, response) { + return new Promise((resolve, reject) => { + let btp_state; + try { + btp_state = btp_parse.get_btp_state(response); + } catch (e) { + return reject(e); + } + + async.waterfall([ + cb => integrate_btp_settings(app, tkey, btp_state, cb), + cb => integrate_events(app, tkey, btp_state, cb), + cb => integrate_player_state(app, tkey, btp_state, cb), + cb => integrate_umpires(app, tkey, btp_state, cb), + cb => integrate_btp_scoring_formats(app, tkey, btp_state, cb), + (scoring_formats, cb) => integrate_locations(app, tkey, btp_state, scoring_formats, cb), + (scoring_formats, location_map, cb) => integrate_courts(app, tkey, btp_state, scoring_formats, location_map, cb), + (scoring_formats, location_map, court_map, cb) => integrate_matches(app, tkey, btp_state, scoring_formats, location_map, court_map, cb), + cb => reconcile_match_officials(app, tkey, cb), + cb => normalize_official_visibility(app, tkey, cb), + cb => integrate_now_on_court(app, tkey, cb), + cb => cleanup_entities(app, tkey, btp_state, cb), + ], (err) => { + if (err) { + return reject(err); + } else { + return resolve(true); + } + }); + }); } module.exports = { calculate_match_ids_on_court, craft_match, date_str, - fetch, + sync_btp_data, time_str, // test only _integrate_umpires: integrate_umpires, + _fallback_scoring_format: fallbackScoringFormat, + _normalize_scoring_format: normalizeScoringFormat, + _merge_local_scoring_format: mergeLocalScoringFormat, + _build_official_reference_state: buildOfficialReferenceState, + _compute_official_visibility_patch: computeOfficialVisibilityPatch, + _find_existing_official_for_btp_import: findExistingOfficialForBtpImport, + _reconcile_match_officials: reconcile_match_officials, + _merge_local_match_into_btp_match: mergeLocalMatchIntoBtpMatch, + _sanitize_scoring_format: sanitizeScoringFormat, + _resolve_btp_dependency_link, + _set_type_to_end_max: setTypeToEndMax, }; diff --git a/bts/bts.js b/bts/bts.js index e197362..c43d69f 100644 --- a/bts/bts.js +++ b/bts/bts.js @@ -15,6 +15,7 @@ const btp_manager = require('./btp_manager'); const bupws = require('./bupws'); const database = require('./database'); const http_api = require('./http_api'); +const match_utils = require('./match_utils'); const serror = require('./serror'); const shortcuts = require('./shortcuts'); const ticker_manager = require('./ticker_manager'); @@ -49,6 +50,7 @@ function main() { }, function (config, db, cb) { const app = create_app(config, db); + match_utils.start_technical_official_pause_manager(app); btp_manager.init(app, (err) => cb(err, app)); }, function(app, cb) { @@ -80,14 +82,10 @@ function cadmin_router() { } function create_app(config, db) { - const server = require('http').createServer(); + const app = express(); - const wss = new ws_module.Server({server: server}); - app.config = config; app.db = db; - app.wss = wss; - app.use('/bup/', express.static(config.bup_location, {index: config.bup_index})); app.use('/bupdev/', express.static(path.join(utils.root_dir(), 'static/bup/dev/'))); app.use('/static/', express.static('static/', {})); @@ -100,12 +98,22 @@ function create_app(config, db) { app.use('/u(:courtnum)?', shortcuts.umpire_handler); app.use(body_parser.json()); - app.get('/h/:tournament_key/courts', http_api.courts_handler); - app.get('/h/:tournament_key/matches', http_api.matches_handler); - app.post('/h/:tournament_key/m/:match_id/score', http_api.score_handler); app.get('/h/:tournament_key/m/:match_id/info', http_api.matchinfo_handler); app.get('/h/:tournament_key/logo/:logo_id', http_api.logo_handler); + var server = null; + if (config.enable_https) { + const options = { + key: fs.readFileSync(config.https_key), + cert: fs.readFileSync(config.https_cert) + } + server = require('https').createServer(options, app); + } else { + server = require('http').createServer(app); + } + + const wss = new ws_module.Server({ server: server }); + app.wss = wss; wss.on('connection', function connection(ws, req) { const location = url.parse(req.url, true); if (location.path === '/ws/admin') { @@ -121,10 +129,27 @@ function create_app(config, db) { } }); - server.on('request', app); - server.listen(config.port, function () { - // console.log('Listening on ' + server.address().port); - }); + if (config.enable_https) { + server.listen(config.https_port, function () { + console.log("HTTPS server listening on port " + config.https_port); + }); + const httpApp = express(); + httpApp.get("*", function (req, res, next) { + var host = req.headers.host.split(":")[0] + if (config.https_port != 443) { + host = host + ":" + config.https_port + } + res.redirect("https://" + host + req.path); + }); + + require('http').createServer(httpApp).listen(config.port, function () { + console.log("HTTP server listening on port " + config.port+" ==> permanently redirected to https."); + }); + } else { + server.listen(config.port, function () { + console.log("HTTP server listening on port " + config.port); + }); + } return app; } diff --git a/bts/bupws.js b/bts/bupws.js index 7efa88b..7295c4d 100644 --- a/bts/bupws.js +++ b/bts/bupws.js @@ -1,19 +1,1159 @@ -'use strict'; +'use strict'; +const async = require('async'); +const serror = require('./serror'); +const utils = require('./utils'); +const admin = require('./admin'); +const cp = require("child_process"); +const os = require("os"); +const dns = require("dns"); +const net = require("net"); -function handle(/*app, ws*/) { - // TODO do something +const btp_manager = require('./btp_manager'); +const btp_conn = require('./btp_conn'); +const ticker_manager = require('./ticker_manager'); +const update_queue = require('./update_queue'); +const match_automation = require('./match_automation'); +const stournament = require('./stournament'); +const all_panels = []; + +const default_tournament_key = 'default'; +const default_displaysettings_key = default_tournament_key; +function on_close(app, ws) { + if (!utils.remove(all_panels, ws)) { + serror.silent('Removing Scoreboard ws, but it was not connected!?'); + } + notify_admin_display_status_changed(app, ws, false); +} + +async function on_connect(app, ws) { + all_panels.push(ws); + notify_admin_display_status_changed(app, ws, true); +} + +async function notify_admin_display_status_changed(app, ws, ws_online) { + app.db.tournaments.findOne({ key: default_tournament_key }, async (err, tournament) => { + if (!err || !tournament) { + err = { message: 'No tournament ' + default_tournament_key }; + } + const client_id = determine_client_id(ws); + const hostname = await determine_client_hostname(ws); + var display_court_displaysetting = await get_display_court_displaysettings(app, client_id); + if (display_court_displaysetting == null) { + display_court_displaysetting = create_display_court_displaysettings(client_id, hostname, null, generate_default_displaysettings_id(tournament)); + display_court_displaysetting = await persist_client_court_displaysetting(app, display_court_displaysetting); + } + display_court_displaysetting.online = ws_online; + admin.notify_change(app, default_tournament_key, 'display_status_changed', {'display_court_displaysetting': display_court_displaysetting }); + }); +} + +function generate_default_displaysettings_id(tournament) { + return (tournament && tournament.displaysettings_general) ? tournament.displaysettings_general : default_displaysettings_key; +} + +function notify_change(app, tournament_key, court_id, ctype, val) { + for (const panel_ws of all_panels) { + notify_change_ws(app, panel_ws, tournament_key, court_id, ctype, val); + } +} + +function notify_change_broadcast(app, tournament_key, ctype, val) { + for (const panel_ws of all_panels) { + notify_change_send(app, panel_ws, tournament_key, ctype, val); + } +} + +function _clear_court_match_reference_after_finish(app, tournament_key, court_q, court, match_id, finish_confirmed, callback) { + if (!finish_confirmed || !court || court.match_id !== match_id) { + return callback(null, false); + } + app.db.courts.update( + court_q, + { $set: { match_id: null } }, + { returnUpdatedDocs: true }, + (err, _numAffected, updated_court) => { + if (err) { + return callback(err); + } + if (updated_court) { + admin.notify_change(app, tournament_key, 'court_changed', { + court_id: updated_court._id, + is_active: updated_court.is_active, + has_umpire: updated_court.has_umpire, + has_service_judge: updated_court.has_service_judge, + match_id: null, + }); + } + callback(null, !!updated_court); + } + ); +} + +function notify_change_ws(app, ws, tournament_key, court_id, ctype, val) { + if (ws == null) { + notify_change(app, tournament_key, court_id, ctype, val); + } else { + if (ws.court_id === court_id) { + notify_change_send(app, ws, tournament_key, ctype, val); + } + } +} + +function notify_change_send(app, ws, tournament_key, ctype, val) { + admin.notify_change(app, tournament_key, 'display_wait_for_done', {'ctype': ctype, 'val' : val, 'client_id': ws.client_id}); + ws.sendmsg({ + type: 'change', + tournament_key, + ctype, + val, + }); +} + +function send_courts(app, ws, tournament_key) { + stournament.get_courts(app.db, tournament_key, function (err, courts) { + notify_change_ws(app, ws,tournament_key, ws.court_id, "courts-update", courts); + }); +} +function send_error(ws, tournament_key, msg) { + ws.sendmsg({ + type: 'error', + tournament_key, + msg + }); +} + +function all_matches_delivery() { + for (const panel_ws of all_panels) { + if (panel_ws.court_id === undefined) { + return true; + } + } +} + +async function handle_reset_display_settings(app, ws, msg) { + const client_id = determine_client_id(ws); + var client_court_displaysetting = await get_display_court_displaysettings(app, client_id); + if (client_court_displaysetting != null) { + const updatevalues = { + client_id: 'deleted' + } + client_court_displaysetting = await update_client_court_displaysetting(app, client_court_displaysetting.client_id, updatevalues); + } +} + +async function handle_persist_display_settings(app, ws, msg) { + const tournament_key = msg.tournament_key; + const court_id = msg.panel_settings.court_id; + var setting = msg.panel_settings; + + const client_id = determine_client_id(ws); + const hostname = await determine_client_hostname(ws); + var client_court_displaysetting = await get_display_court_displaysettings(app, client_id); + if (client_court_displaysetting == null) { + setting.id = tournament_key + "_" + court_id + " _" + Date.now(); + setting = await persist_displaysetting(app, tournament_key, setting); + client_court_displaysetting = create_display_court_displaysettings(client_id, hostname, court_id, setting.id); + client_court_displaysetting = await persist_client_court_displaysetting(app, client_court_displaysetting); + } else { + setting.id = tournament_key + "_" + court_id + " _" + Date.now(); + setting = await persist_displaysetting(app, tournament_key, setting); + const updatevalues = { + court_id: court_id, + displaysetting_id: setting.id, + } + client_court_displaysetting = await update_client_court_displaysetting(app, client_court_displaysetting.client_id, updatevalues); + } +} +async function handle_score_update(app, ws, msg) { + return update_queue.instance().execute(update_queue.named('handle_score_update', () => new Promise((resolve) => { + const match_utils = require('./match_utils'); + const tournament_key = msg.tournament_key; + const score_data = msg.score; + const match_id = score_data.match_id; + let finished = false; + const finish = (err) => { + if (finished) { + return; + } + finished = true; + clearTimeout(timeout); + if (err) { + send_error(ws, tournament_key, err.message || String(err)); + } + resolve(); + }; + const timeout = setTimeout(() => { + finish(new Error('handle_score_update timeout')); + }, 5000); + + (async () => { + let match = null; + let tournament = null; + let court = null; + try { + const fetch_tournament = new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, (err, found_tournament) => { + if (err) { + return reject(err); + } + resolve(found_tournament); + }); + }); + const fetch_court = new Promise((resolve, reject) => { + app.db.courts.findOne({ tournament_key, _id: score_data.court_id }, (err, found_court) => { + if (err) { + return reject(err); + } + resolve(found_court); + }); + }); + [match, tournament, court] = await Promise.all([ + match_utils.fetch_match(app, tournament_key, match_id), + fetch_tournament, + fetch_court, + ]); + } catch { + match = null; + tournament = null; + court = null; + } + const finish_confirmed = score_data.finish_confirmed ? score_data.finish_confirmed : false; + const allow_finished_confirmation = finish_confirmed && (score_data.team1_won !== undefined && score_data.team1_won !== null); + if (match == null || (match.setup.now_on_court == false && !allow_finished_confirmation)) { + send_error(ws, tournament_key, "Match not found or not on court actualy."); + return finish(); + } + if (!court) { + send_error(ws, tournament_key, "Court for score update not found."); + return finish(); + } + if (ws.court_id && score_data.court_id && ws.court_id !== score_data.court_id) { + send_error(ws, tournament_key, "Score update rejected: panel is assigned to a different court."); + return finish(); + } + if (match.setup && match.setup.court_id && score_data.court_id && match.setup.court_id !== score_data.court_id) { + send_error(ws, tournament_key, "Score update rejected: match is assigned to a different court."); + return finish(); + } + const expected_match_for_court = + court.match_id === match_id || + (!court.match_id && match.setup && match.setup.court_id === score_data.court_id && match.setup.now_on_court === true); + if (!expected_match_for_court) { + send_error(ws, tournament_key, "Score update rejected: stale panel state for this court."); + return finish(); + } + + const update = { + network_score: score_data.network_score, + network_team1_left:score_data.network_team1_left, + network_team1_serving:score_data.network_team1_serving, + network_teams_player1_even:score_data.network_teams_player1_even, + presses:score_data.presses, + duration_ms:score_data.duration_ms, + end_ts:score_data.end_ts, + 'setup.now_on_court': true, + 'setup.state': 'oncourt', + }; + + const device_info = score_data.device; + if (device_info) { + const client_ip = ws._socket.remoteAddress; + device_info.client_ip = client_ip; + } + + if (finish_confirmed) { + update["setup.now_on_court"] = false; + update["setup.state"] = 'finished'; + update.team1_won = score_data.team1_won; + update.btp_winner = (update.team1_won === true) ? 1 : 2; + update.btp_needsync = true; + } + + if (score_data.shuttle_count) { + update.shuttle_count = score_data.shuttle_count; + } + + const simulated_match = { + ...match, + network_score: update.network_score, + team1_won: update.team1_won, + setup: { + ...match.setup, + now_on_court: update['setup.now_on_court'], + state: update['setup.state'], + }, + }; + const preparation_successor_state = match_automation.calculate_preparation_successor_state(simulated_match, tournament); + update['setup.needs_preparation_successor'] = preparation_successor_state.needs_preparation_successor; + update['setup.needs_preparation_successor_ts'] = preparation_successor_state.needs_preparation_successor_ts; + + const match_query = { + _id: match_id, + tournament_key, + }; + + const court_q = { + tournament_key, + _id: score_data.court_id, + }; + const db = app.db; + async.waterfall([ + cb => { + db.matches.update(match_query, { $set: update }, { returnUpdatedDocs: true }, (err, _, updated_match) => cb(err, updated_match)); + }, + (updated_match, cb) => { + if (updated_match) { + handle_score_change(app, tournament_key, updated_match.setup.court_id); + admin.notify_change(app, tournament_key, 'score', { + match_id, + network_score: update.network_score, + team1_won: update.team1_won, + shuttle_count: update.shuttle_count, + presses: updated_match.presses, + court_id: updated_match.setup && updated_match.setup.court_id, + now_on_court: updated_match.setup && updated_match.setup.now_on_court, + }); + } + cb(null, updated_match); + }, + (updated_match, cb) => { + if (updated_match && finish_confirmed) { + btp_manager.update_score(app, updated_match); + match_utils.reset_player_tabletoperator(app, tournament_key, match_id, update.end_ts) + .then(() => cb(null, updated_match)) + .catch((err) => { + console.error("Error in reset_player_tabletoperator:", err); + cb(err); + }); + return; + } + cb(null, updated_match); + }, + (updated_match, cb) => { + cb(null, updated_match, court); + }, + (updated_match, court, cb) => { + if (!court) { + return cb(new Error('Cannot find court ' + JSON.stringify(score_data.court_id))); + } + if (!updated_match) { + if (court.match_id === match_id) { + cb(null, updated_match, court, false); + return; + } + + db.courts.update(court_q, { $set: { match_id: match_id } }, {}, (err) => { + cb(err, updated_match, court, true); + }); + return; + } + cb(null, updated_match, court, true); + }, + (updated_match, court, changed_court, cb) => { + if (updated_match && changed_court) { + admin.notify_change(app, tournament_key, 'court_current_match', { + match__id: match_id, + match: updated_match, + }); + } + cb(null, updated_match, changed_court); + }, + (updated_match, changed_court, cb) => { + if (updated_match && updated_match.setup.highlight && + updated_match.setup.highlight == 6 && + updated_match.network_score && + updated_match.network_score.length > 0 && + updated_match.network_score[0].length > 1 && + (updated_match.network_score[0][0] > 0 || updated_match.network_score[0][1] > 0)) { + updated_match.setup.highlight = 0; + match_utils.normalize_preparation_state(updated_match.setup); + btp_manager.update_highlight(app, updated_match); + } + cb(null, updated_match, changed_court); + }, + (updated_match, changed_court, cb) => { + if (changed_court) { + ticker_manager.pushall(app, tournament_key); + } else if (updated_match) { + ticker_manager.update_score(app, updated_match); + } + cb(null, updated_match, changed_court); + }, + (updated_match, changed_court, cb) => { + _clear_court_match_reference_after_finish(app, tournament_key, court_q, court, match_id, finish_confirmed, (err) => { + if (err) { + return cb(err); + } + cb(null, updated_match, changed_court); + }); + }, + (updated_match, changed_court, cb) => { + if (!updated_match) { + return cb(new Error('Cannot find match ' + JSON.stringify(updated_match))); + } + match_utils.auto_execute_preparation_selection_for_setup(app, tournament, updated_match.setup, (err) => { + if (err) { + return cb(err); + } + return cb(null, updated_match, changed_court); + }); + }, + (updated_match, changed_court, cb) => { + if (!finish_confirmed || !score_data.court_id) { + return cb(null, updated_match, changed_court); + } + match_utils.call_preparation_match_on_court(app, tournament_key, score_data.court_id) + .then(() => cb(null, updated_match, changed_court)) + .catch((err) => { + const message = err && (err.message || String(err)); + if (/No match found to call on court/.test(message)) { + return cb(null, updated_match, changed_court); + } + return cb(err); + }); + }, + (updated_match, changed_court, cb) => { + if (!device_info) { + return cb(null, updated_match, changed_court); + } + update_device_info(app, tournament_key, device_info); + return cb(null, updated_match, changed_court); + }, + ], finish); + })().catch(finish); + }))); +} +async function handle_device_info(app, ws, msg) { + const tournament_key = msg.tournament_key; + const device_info = msg.device; + if (device_info) { + device_info.client_ip = ws._socket.remoteAddress; + update_device_info(app, tournament_key, device_info); + } +} +async function update_device_info(app, tournament_key, device_info) { + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (!err || !tournament) { + err = { message: 'No tournament ' + default_tournament_key }; + } + const client_id = determine_client_id_from_ip(device_info.client_ip); + const panel = fetch_panel(client_id); + if (panel != null) { + const hostname = await determine_client_hostname(panel); + var display_court_displaysetting = await get_display_court_displaysettings(app, client_id); + if (display_court_displaysetting == null) { + display_court_displaysetting = create_display_court_displaysettings(client_id, hostname, panel.court_id, generate_default_displaysettings_id(tournament)); + } else { + display_court_displaysetting.hostname = hostname; + } + panel.battery = device_info.battery + display_court_displaysetting.battery = device_info.battery + display_court_displaysetting.online = true; + admin.notify_change(app, default_tournament_key, 'display_status_changed', { 'display_court_displaysetting': display_court_displaysetting }); + } + }); +} + +function fetch_panel(client_id) { + for (const panel_ws of all_panels) { + if (client_id == panel_ws.client_id) { + return panel_ws; + } + } + return null; +} + + +function create_display_court_displaysettings(client_id, hostname, court_id, displaysetting_id) { + return { + client_id: client_id, + hostname: hostname, + court_id: court_id, + displaysetting_id: displaysetting_id, + } +} + +async function handle_init(app, ws, msg) { + const tournament_key = msg.tournament_key; + var court_id = msg.panel_settings.court_id; + if (court_id) { + ws.court_id = court_id; + } else { + ws.court_id = undefined; + court_id = undefined; + } + if (msg.initialize_display) { + initialize_client(ws, app, tournament_key, court_id); + } else { + matches_handler(app, ws, tournament_key, ws.court_id); + } + send_courts(app, ws, tournament_key); +} + +async function send_finshed_confirmed(app, tournament_key, court_id) { + notify_change(app, tournament_key, court_id, 'confirm-match-finished', {}); +} + +async function send_advertisement_add(app, tournament_key, advertisement) { + notify_change_broadcast(app, tournament_key, 'advertisement_add', advertisement); +} + +async function send_advertisement_remove(app, tournament_key, advertisement_id) { + notify_change_broadcast(app, tournament_key, 'advertisement_remove', { advertisement_id: advertisement_id }); +} + +async function initialize_client(ws, app, tournament_key, court_id, displaysetting_id) { + const client_id = determine_client_id(ws); + const hostname = await determine_client_hostname(ws); + if (client_id) { + let display_setting = await get_display_setting(app, tournament_key, client_id, court_id, displaysetting_id) + if (display_setting != null) { + ws.court_id = display_setting.court_id; + court_id = display_setting.court_id; + notify_change_ws(app, ws, tournament_key, court_id, "settings-update", display_setting); + } + } + matches_handler(app, ws, tournament_key, ws.court_id); +} + +function getComputerName() { + try { + switch (process.platform) { + case "win32": + return process.env.COMPUTERNAME || os.hostname(); + case "darwin": + return cp.execSync("scutil --get ComputerName").toString().trim(); + case "linux": + const prettyname = cp.execSync("hostnamectl --pretty").toString().trim(); + return prettyname || os.hostname(); + default: + return os.hostname(); + } + } catch (err) { + console.error("Error getting computer name:", err); + return os.hostname(); + } +} + +function extractIPv4FromMappedIPv6(ip) { + const match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + return match ? match[1] : null; +} + +async function determine_client_hostname(ws) { + if (ws.hostname) { + return ws.hostname; + } + + let remoteAddress = ws._socket.remoteAddress; + let ipv4 = extractIPv4FromMappedIPv6(remoteAddress); + let ip = ipv4 || remoteAddress; + + // Lokale Adressen behandeln + if (ip === "127.0.0.1") { + ws.hostname = getComputerName(); + return ws.hostname; + } + + // Bei ungültiger IP + if (!net.isIP(ip)) { + console.error("Invalid IP address:", remoteAddress); + ws.hostname = "N/N"; + return ws.hostname; + } + + // 1. Falls IPv4 verfügbar → versuchen Reverse-Lookup + if (ipv4) { + try { + const hostnames = await dnsReverseWithTimeout(ipv4, 3000); + ws.hostname = hostnames?.[0]?.split(".")[0] || ipv4; + return ws.hostname; + } catch (err) { + if (err.code !== 'ENOTFOUND') { + console.error("IPv4 DNS reverse lookup failed:", err); + } + // IPv4 Lookup fehlgeschlagen → weiter mit IPv6 versuchen + ip = remoteAddress; // original IPv6 verwenden + } + } + + // 2. Jetzt IPv6 Reverse-Lookup versuchen + if (net.isIPv6(ip)) { + if (ip === "::1") { + ws.hostname = getComputerName(); + return ws.hostname; + } + + try { + const hostnames = await dnsReverseWithTimeout(ip, 3000); + ws.hostname = hostnames?.[0]?.split(".")[0] || ipv4 || ip; + return ws.hostname; + } catch (err) { + if (err.code !== 'ENOTFOUND') { + console.error("IPv6 DNS reverse lookup failed:", err); + } + // 3. Fallback: IPv4-Adresse als Text + ws.hostname = ipv4 || ip; + return ws.hostname; + } + } + + // Sollte nicht vorkommen, aber falls doch: + ws.hostname = ipv4 || ip; + return ws.hostname; +} + +// Hilfsfunktion: extrahiert IPv4 aus gemapptem IPv6, z.B. ::ffff:192.168.0.1 => 192.168.0.1 +function extractIPv4FromMappedIPv6(address) { + if (typeof address !== "string") return null; + const match = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + return match ? match[1] : null; +} + +// Hilfsfunktion: DNS-Reverse mit Timeout +function dnsReverseWithTimeout(ip, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("DNS reverse lookup timeout")); + }, timeoutMs); + + dns.reverse(ip, (err, hostnames) => { + clearTimeout(timer); + if (err) { + reject(err); + } else { + resolve(hostnames); + } + }); + }); +} + + +function determine_client_id(ws) { + if (!ws.client_id) { + ws.client_id = determine_client_id_from_ip (ws._socket.remoteAddress); + } + return ws.client_id; +} + +function determine_client_id_from_ip(ip_adress) { + if (ip_adress) { + const remote_adress_seqments = ip_adress.split('.'); + return remote_adress_seqments[remote_adress_seqments.length - 1]; + } else { + return "UNDEFINED"; + } +} + +function persist_client_court_displaysetting(app, client_court_displaysetting) { + return new Promise((resolve, reject) => { + app.db.display_court_displaysettings.insert(client_court_displaysetting, function (err, inserted_t) { + if (err) { + reject(err); + } + resolve(inserted_t); + }); + }); +} + +function update_client_court_displaysetting(app, client_court_displaysetting_id, updatevalues) { + return new Promise((resolve, reject) => { + app.db.display_court_displaysettings.update({ client_id: client_court_displaysetting_id }, { $set: updatevalues }, { returnUpdatedDocs: true }, function (err, numAffected, changed_objects) { + if (err) { + reject(err) + } + resolve(changed_objects) + + }); + }); +} + + +function persist_displaysetting(app, tournament_key, setting) { + setting._id = undefined; + return new Promise((resolve, reject) => { + app.db.displaysettings.insert(setting, function (err, inserted_t) { + if (err) { + reject(err); + } + admin.notify_change(app, tournament_key, 'update_display_setting', {setting: inserted_t}); + resolve(inserted_t); + }); + }); +} + + +function client_id(app, tkey, client_id) { + return new Promise((resolve, reject) => { + const display_court_query = { 'client_id': client_id }; + app.db.display_court_displaysettings.find(display_court_query).limit(1).exec((err, display_court_displaysetting) => { + if (err) { + return reject(err); + } + var returnvalue = null; + if (display_court_displaysetting.length == 1) { + returnvalue = display_court_displaysetting[0]; + } + resolve(returnvalue); + }); + }); +} + +function get_display_court_displaysettings(app, client_id) { + return new Promise((resolve, reject) => { + const display_court_query = { 'client_id': client_id }; + app.db.display_court_displaysettings.find(display_court_query).limit(1).exec((err, display_court_displaysetting) => { + if (err) { + return reject(err); + } + if (display_court_displaysetting.length == 1) { + resolve(display_court_displaysetting[0]); + } + resolve(null); + }); + }); +} +function get_display_setting(app, tkey, client_id, court_id, displaysetting) { + return new Promise((resolve, reject) => { + const display_court_query = { 'client_id': client_id }; + app.db.display_court_displaysettings.find(display_court_query).limit(1).exec((err, display_court_displaysetting) => { + if (err) { + return reject(err); + } + var returnvalue = null; + if (display_court_displaysetting.length == 1) { + const display_query = { 'id': display_court_displaysetting[0].displaysetting_id }; + app.db.displaysettings.find(display_query).limit(1).exec((err, display_setting) => { + if (err) { + return reject(err); + } + if (display_setting.length == 1) { + returnvalue = display_setting[0]; + returnvalue.court_id = display_court_displaysetting[0].court_id; + returnvalue.displaymode_court_id = display_court_displaysetting[0].court_id; + } + app.db.advertisements.find({}, function (err, advertisements) { + if (err) { + return resolve(returnvalue); + } + if(returnvalue) { + returnvalue.advertisements = advertisements; + } + resolve(returnvalue); + + }); + }); + } else { + app.db.tournaments.findOne({ key: tkey }, async (err, tournament) => { + if (err || !tournament) { + return reject(err); + } + var displaysetting_id = generate_default_displaysettings_id(tournament); + if (displaysetting) { + displaysetting_id = displaysetting; + } + const display_query_default = { 'id': displaysetting_id }; + app.db.displaysettings.find(display_query_default).limit(1).exec((err, display_setting_default) => { + if (err) { + return reject(err); + } + if (display_setting_default.length == 1) { + returnvalue = display_setting_default[0]; + returnvalue.court_id = court_id; + returnvalue.displaymode_court_id = court_id; + } + app.db.advertisements.find({}, function (err, advertisements) { + if (err) { + return resolve(returnvalue); + } + returnvalue.advertisements = advertisements; + resolve(returnvalue); + + }); + }); + }); + } + }); + }); +} + +function handle_command_done(app, ws, msg) { + admin.notify_change(app, msg.tournament_key, 'display_is_done', {'ctype': msg.wait_for_command.ctype, 'val' : msg.wait_for_command.val, 'client_id': ws.client_id}); +} + +function handle_score_change(app, tournament_key, court_id) { + console.log('[bts] auto_call_trace:bup_handle_score_change', { + ts: Date.now(), + tournament_key, + court_id: court_id || null, + all_matches_delivery: !!all_matches_delivery(), + }); + matches_handler(app, null, tournament_key, court_id); + if (all_matches_delivery()) { + matches_handler(app, null, tournament_key, undefined); + } +} + +function get_bup_match_priority(match, prefer_finished_first) { + if (!match || !match.setup) { + return 99; + } + if (prefer_finished_first) { + if (match.setup.state === 'finished') { + return 0; + } + if (match.setup.now_on_court === true) { + return 1; + } + if (match.setup.state === 'oncourt') { + return 2; + } + if (match.setup.state === 'blocked') { + return 3; + } + return 4; + } + if (match.setup.now_on_court === true) { + return 0; + } + if (match.setup.state === 'oncourt') { + return 1; + } + if (match.setup.state === 'blocked') { + return 2; + } + if (match.setup.state === 'finished') { + return 3; + } + return 4; +} + +function cmp_bup_matches(a, b, prefer_finished_first) { + const priority_diff = get_bup_match_priority(a, prefer_finished_first) - get_bup_match_priority(b, prefer_finished_first); + if (priority_diff !== 0) { + return priority_diff; + } + + const a_called = (a && a.setup && a.setup.called_timestamp) || 0; + const b_called = (b && b.setup && b.setup.called_timestamp) || 0; + if (a_called !== b_called) { + return b_called - a_called; + } + + const a_end = a && a.end_ts ? a.end_ts : 0; + const b_end = b && b.end_ts ? b.end_ts : 0; + if (a_end !== b_end) { + return b_end - a_end; + } + + const a_id = (a && a.setup && a.setup.match_id) || ''; + const b_id = (b && b.setup && b.setup.match_id) || ''; + return a_id.localeCompare(b_id); +} + +function matches_handler(app, ws, tournament_key, court_id) { + const now = Date.now(); + const show_still = now - 60000; + const query = { + tournament_key, + $or: [ + { + $and: [ + { + team1_won: { + $ne: true, + }, + }, + { + team1_won: { + $ne: false, + }, + }, + ], + }, + { + end_ts: { + $gt: show_still, + }, + }, + ], + }; + if (court_id) { + query['setup.court_id'] = court_id; + } else { + query['setup.court_id'] = { $exists: true }; + } + + app.db.fetch_all([{ + queryFunc: '_findOne', + collection: 'tournaments', + query: { key: tournament_key }, + }, { + collection: 'matches', + query, + }, { + collection: 'courts', + query: { tournament_key }, + }], function (err, tournament, db_matches, db_courts) { + if (err) { + const msg = { + status: 'error', + message: err.message, + }; + notify_change_ws(app, tournament_key, "score-update", msg); + } + + if(db_matches){ + let matches = db_matches.map(dbm => create_match_representation(tournament, dbm)); + if (!court_id) { + matches = matches.filter(m => m.setup.now_on_court); + } + matches = matches.filter(m => m.setup.state == 'oncourt' || m.setup.state == 'finished' || m.setup.state == 'blocked'); + matches.sort((a, b) => cmp_bup_matches(a, b, !!court_id)); + + db_courts.sort(utils.cmp_key('num')); + const courts = db_courts.map(function (dc) { + var res = { + court_id: dc._id, + label: dc.num, + }; + if (dc.match_id) { + res.match_id = 'bts_' + dc.match_id; + } + if (dc.called_timestamp) { + res.called_timestamp = dc.called_timestamp; + } + return res; + }); + + + const event = create_event_representation(tournament); + event.matches = matches; + event.courts = courts; + console.log('[bts] auto_call_trace:bup_score_update_payload', { + ts: Date.now(), + tournament_key, + court_id: court_id || null, + match_states: matches.map((match) => ({ + match_id: match && match.setup && match.setup.match_id, + state: match && match.setup && match.setup.state, + now_on_court: match && match.setup && match.setup.now_on_court, + called_timestamp: match && match.setup && match.setup.called_timestamp, + end_ts: match && match.end_ts, + })), + }); + const reply = { + status: 'ok', + event, + }; + notify_change_ws(app, ws, tournament_key, court_id, "score-update", reply) + } + }); +} + +function create_match_representation(tournament, match) { + const setup = match.setup; + setup.match_id = 'bts_' + match._id; + setup.team_competition = tournament.is_team; + setup.nation_competition = tournament.is_nation_competition; + for (const t of setup.teams) { + if (!t.players) continue; + + for (const p of t.players) { + if (p.lastname) continue; + + const asian_m = /^([A-Z]+)\s+(.*)$/.exec(p.name); + if (asian_m) { + p.lastname = asian_m[1]; + p.firstname = asian_m[2]; + p._guess_info = 'bts_asian'; + continue; + } + + const m = /^(.*)\s+(\S+)$/.exec(p.name); + if (m) { + p.firstname = m[1]; + p.lastname = m[2]; + p._guess_info = 'bts_western'; + } else { + p.firstname = ''; + p.lastname = p.name; + p._guess_info = 'bts_single'; + } + } + } + + const res = { + setup, + network_score: match.network_score, + network_team1_left: match.network_team1_left, + network_team1_serving: match.network_team1_serving, + network_teams_player1_even: match.network_teams_player1_even, + end_ts: match.end_ts !== undefined ? match.end_ts : null, + }; + if (match.presses) { + res.presses_json = JSON.stringify(match.presses); + } + return res; +} + +function create_event_representation(tournament) { + const res = { + id: 'bts_' + tournament.key, + tournament_name: tournament.name, + }; + if (tournament.logo_id) { + res.tournament_logo_url = `/h/${encodeURIComponent(tournament.key)}/logo/${tournament.logo_id}`; + } + else { + try { + const fs = require('fs'); + const path = require('path'); + const d = new Date(); + const datestring = d.toISOString().slice(0, 10); + const filename = "logo/" + datestring +"_"+tournament._id + ".png"; + const filepath = path.join(utils.root_dir(), 'data', 'logos', datestring +"_"+tournament._id + ".png"); + if (!fs.existsSync(filepath)) { + const qrcode = require('qrcode'); + const url = admin.generate_tournament_web_url(tournament); + qrcode.toFile(filepath, url, { scale: 7, errorCorrectionLevel: 'H' }, function (error) { }); + } + res.tournament_logo_url = `/h/${encodeURIComponent(tournament.key)}/${filename}`; + } catch (error) { + console.log("A error occured during generating QR-Code for displays"); + } + } + res.tournament_logo_background_color = tournament.logo_background_color || '#000000'; + res.tournament_logo_foreground_color = tournament.logo_foreground_color || '#aaaaaa'; + return res; +} + +async function restart_panel(app, tournament_key, client_id, new_court_id) { + var client_court_displaysetting = null; + if (new_court_id) { + if (new_court_id == "--") { + new_court_id = undefined; + } + + const updatevalues = { + court_id: new_court_id + } + client_court_displaysetting = await update_client_court_displaysetting(app, client_id, updatevalues); + } + var display_online = reinitialize_panel(app, tournament_key, client_id, new_court_id, undefined); + if (client_court_displaysetting != null) { + client_court_displaysetting.online = display_online; + admin.notify_change(app, tournament_key, 'display_status_changed', { 'display_court_displaysetting': client_court_displaysetting }); + } + +} + +async function change_display_mode(app, tournament_key, client_id, new_displaysettings_id) { + if (new_displaysettings_id) { + const updatevalues = { + displaysetting_id: new_displaysettings_id + } + const client_court_displaysetting = await update_client_court_displaysetting(app, client_id, updatevalues); + var display_online = reinitialize_panel(app, tournament_key, client_id, null, new_displaysettings_id); + if (client_court_displaysetting) { + client_court_displaysetting.online = display_online; + admin.notify_change(app, tournament_key, 'display_status_changed', { 'display_court_displaysetting': client_court_displaysetting }); + } + } +} +async function change_default_display_mode(app, tournament, old_displaysettings_id, new_displaysettings_id) { + if (new_displaysettings_id) { + app.db.display_court_displaysettings.find({ displaysetting_id: old_displaysettings_id }).exec( async (err, display_court_displaysettings) => { + if (err) { + return reject(err); + } + const updatevalues = { + displaysetting_id: new_displaysettings_id + } + for (const displaysettings of display_court_displaysettings) { + const client_court_displaysetting = await update_client_court_displaysetting(app, displaysettings.client_id, updatevalues); + if (client_court_displaysetting) { + var display_online = reinitialize_panel(app, tournament.key, displaysettings.client_id, null, undefined); + + } + } + for (const panel_ws of all_panels) { + restart_panel(app, tournament.key, panel_ws.client_id); + } + }); + } } -function on_close() { - // TODO update client list + + +function reinitialize_panel(app, tournament_key, client_id, new_court_id, displaysetting_id) { + for (const panel_ws of all_panels) { + const ws_client_id = determine_client_id(panel_ws); + if (client_id == ws_client_id) { + if (new_court_id != null) { + panel_ws.court_id = new_court_id; + } + initialize_client(panel_ws, app, tournament_key, panel_ws.court_id, displaysetting_id); + return true; + } + } + return false;; } -function on_connect() { - // TODO update client list +async function add_display_status(app, tournament, displays, callback) { + for (const d of displays) { + d.online = false; + for (const panel_ws of all_panels) { + const ws_client_id = determine_client_id(panel_ws); + if (d.client_id == ws_client_id) { + d.online = true; + d.battery = panel_ws.battery; + d.hostname = await determine_client_hostname(panel_ws); + break; + } + } + } + for (const panel_ws of all_panels) { + var found = false; + const ws_client_id = determine_client_id(panel_ws); + for (const d of displays) { + if (d.client_id == ws_client_id) { + found = true; + break; + } + } + if (!found) { + const ws_hostname = await determine_client_hostname(panel_ws); + const client_court_displaysetting = create_display_court_displaysettings(ws_client_id, ws_hostname, panel_ws.court_id, generate_default_displaysettings_id(tournament)); + client_court_displaysetting.online = true; + client_court_displaysetting.battery = panel_ws.battery; + displays[displays.length] = client_court_displaysetting; + + } + } + return callback(displays); } module.exports = { - handle, on_close, on_connect, -}; \ No newline at end of file + notify_change, + handle_init, + handle_command_done, + handle_score_change, + handle_persist_display_settings, + handle_reset_display_settings, + handle_score_update, + handle_device_info, + update_device_info, + restart_panel, + send_finshed_confirmed, + send_advertisement_add, + send_advertisement_remove, + change_display_mode, + change_default_display_mode, + add_display_status, + create_match_representation, + create_event_representation, + _clear_court_match_reference_after_finish, +}; diff --git a/bts/database.js b/bts/database.js index 896c2ad..666dff0 100644 --- a/bts/database.js +++ b/bts/database.js @@ -11,18 +11,24 @@ const utils = require('./utils'); const TABLES = [ 'courts', + 'locations', 'event', 'matches', 'tournaments', 'umpires', 'logs', + 'tabletoperators', + 'displaysettings', + 'display_court_displaysettings', + 'normalizations', + 'advertisements' ]; async function init_test() { const db = {} for (const key of TABLES) { - db[key] = new Datastore({inMemoryOnly: true}); + db[key] = new Datastore({ inMemoryOnly: true }); } await promisify(prepare)(db); return db; @@ -46,7 +52,9 @@ function init(callback) { } TABLES.forEach(function(key) { - db[key] = new Datastore({filename: path.join(db_dir, key), autoload: true}); + var d = new Datastore({ filename: path.join(db_dir, key), autoload: true }); + d.persistence.setAutocompactionInterval(60000*10); + db[key] = d; }); prepare(db, callback); @@ -54,13 +62,15 @@ function init(callback) { function prepare(db, callback) { db.courts.ensureIndex({fieldName: 'tournament_key', unique: false}); + db.locations.ensureIndex({fieldName: 'tournament_key', unique: false}); + db.locations.ensureIndex({fieldName: 'location_id', unique: false}); db.matches.ensureIndex({fieldName: 'court_id', unique: false}); db.matches.ensureIndex({fieldName: 'tournament_key', unique: false}); db.matches.ensureIndex({fieldName: 'event_key', unique: false}); db.tournaments.ensureIndex({fieldName: 'key', unique: true}); db.umpires.ensureIndex({fieldName: 'name', unique: true}); db.umpires.ensureIndex({fieldName: 'tournament_key', unique: false}); - db.logs.ensureIndex({fieldName: 'tournament_key', unique: false}); + db.logs.ensureIndex({ fieldName: 'tournament_key', unique: false }); setup_helpers(db); diff --git a/bts/http_api.js b/bts/http_api.js index 4e8014a..54cd31c 100644 --- a/bts/http_api.js +++ b/bts/http_api.js @@ -1,182 +1,28 @@ 'use strict'; const assert = require('assert'); -const async = require('async'); const path = require('path'); - -const admin = require('./admin'); -const btp_manager = require('./btp_manager'); -const stournament = require('./stournament'); -const ticker_manager = require('./ticker_manager'); const utils = require('./utils'); +const bupws = require('./bupws'); -// Returns true iff all params are met -function _require_params(req, res, keys) { - for (const k of keys) { - if (! Object.prototype.hasOwnProperty.call(req.body, k)) { - res.json({ - status: 'error', - message: 'Missing field ' + k + ' in request', - }); - return false; - } - } - return true; -} - -function courts_handler(req, res) { - const tournament_key = req.params.tournament_key; - stournament.get_courts(req.app.db, tournament_key, function(err, courts) { - const reply = (err ? { - status: 'error', - message: err.message, - } : { - status: 'ok', - courts, - }); - - res.json(reply); - }); -} - -function create_match_representation(tournament, match) { - const setup = match.setup; - setup.match_id = 'bts_' + match._id; - setup.team_competition = tournament.is_team; - setup.nation_competition = tournament.is_nation_competition; - for (const t of setup.teams) { - if (!t.players) continue; - - for (const p of t.players) { - if (p.lastname) continue; - - const asian_m = /^([A-Z]+)\s+(.*)$/.exec(p.name); - if (asian_m) { - p.lastname = asian_m[1]; - p.firstname = asian_m[2]; - p._guess_info = 'bts_asian'; - continue; - } - - const m = /^(.*)\s+(\S+)$/.exec(p.name); - if (m) { - p.firstname = m[1]; - p.lastname = m[2]; - p._guess_info = 'bts_western'; - } else { - p.firstname = ''; - p.lastname = p.name; - p._guess_info = 'bts_single'; - } - } - } - - const res = { - setup, - network_score: match.network_score, - network_team1_left: match.network_team1_left, - network_team1_serving: match.network_team1_serving, - network_teams_player1_even: match.network_teams_player1_even, - }; - if (match.presses) { - res.presses_json = JSON.stringify(match.presses); - } - return res; -} - -function create_event_representation(tournament) { - const res = { - id: 'bts_' + tournament.key, - tournament_name: tournament.name, - }; - if (tournament.logo_id) { - res.tournament_logo_url = `/h/${encodeURIComponent(tournament.key)}/logo/${tournament.logo_id}`; - } - res.tournament_logo_background_color = tournament.logo_background_color || '#000000'; - res.tournament_logo_foreground_color = tournament.logo_foreground_color || '#aaaaaa'; - return res; -} - -function matches_handler(req, res) { - const tournament_key = req.params.tournament_key; - const now = Date.now(); - const show_still = now - 60000; - const query = { - tournament_key, - $or: [ - { - $and: [ - { - team1_won: { - $ne: true, - }, - }, - { - team1_won: { - $ne: false, - }, - }, - ], - }, - { - end_ts: { - $gt: show_still, - }, - }, - ], - }; - if (req.query.court) { - query['setup.court_id'] = req.query.court; - } else { - query['setup.court_id'] = {$exists: true}; - } - - req.app.db.fetch_all([{ - queryFunc: '_findOne', - collection: 'tournaments', - query: {key: tournament_key}, - }, { - collection: 'matches', - query, - }, { - collection: 'courts', - query: {tournament_key}, - }], function(err, tournament, db_matches, db_courts) { - if (err) { - res.json({ - status: 'error', - message: err.message, - }); - return; - } - - let matches = db_matches.map(dbm => create_match_representation(tournament, dbm)); - if (tournament.only_now_on_court) { - matches = matches.filter(m => m?.setup.now_on_court); - } - - db_courts.sort(utils.cmp_key('num')); - const courts = db_courts.map(function(dc) { - var res = { - court_id: dc._id, - label: dc.num, - }; - if (dc.match_id) { - res.match_id = 'bts_' + dc.match_id; - } - return res; - }); - - const event = create_event_representation(tournament); - event.matches = matches; - event.courts = courts; - - const reply = { - status: 'ok', - event, - }; - res.json(reply); - }); +function logo_handler(req, res) { + const {tournament_key, logo_id} = req.params; + assert(tournament_key); + assert(logo_id); + const filetype = logo_id.split(".")[1]; + const mime = { + gif: 'image/gif', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + svg: 'image/svg+xml', + webp: 'image/webp', + }[filetype]; + assert(mime, `Unsupported ext ${JSON.stringify(filetype)}`); + const fn = path.join(utils.root_dir(), 'data', 'logos', path.basename(logo_id)); + res.setHeader('Content-Type', mime); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.sendFile(fn); } function matchinfo_handler(req, res) { @@ -221,8 +67,8 @@ function matchinfo_handler(req, res) { const [tournament] = tournaments; const [match] = matches; - const event = create_event_representation(tournament); - const match_repr = create_match_representation(tournament, match); + const event = bupws.create_event_representation(tournament); + const match_repr = bupws.create_match_representation(tournament, match); if (match_repr.presses_json) { // Parse JSON-in-JSON (for performance reasons) for nicer output match_repr.presses = JSON.parse(match_repr.presses_json); @@ -239,126 +85,8 @@ function matchinfo_handler(req, res) { }); } -function score_handler(req, res) { - if (!_require_params(req, res, ['duration_ms', 'end_ts', 'network_score', 'team1_won', 'presses'])) return; - - const tournament_key = req.params.tournament_key; - const match_id = req.params.match_id; - const query = { - _id: match_id, - tournament_key, - }; - const update = { - network_score: req.body.network_score, - network_team1_left: req.body.network_team1_left, - network_team1_serving: req.body.network_team1_serving, - network_teams_player1_even: req.body.network_teams_player1_even, - team1_won: req.body.team1_won, - presses: req.body.presses, - duration_ms: req.body.duration_ms, - end_ts: req.body.end_ts, - }; - if (update.team1_won !== undefined) { - update.btp_winner = (update.team1_won === true) ? 1 : 2; - update.btp_needsync = true; - } - if (req.body.shuttle_count) { - update.shuttle_count = req.body.shuttle_count; - } - - const court_q = { - tournament_key, - _id: req.body.court_id, - }; - const db = req.app.db; - - async.waterfall([ - cb => db.matches.update(query, {$set: update}, {returnUpdatedDocs: true}, (err, _, match) => cb(err, match)), - (match, cb) => { - if (!match) { - return cb(new Error('Cannot find match ' + JSON.stringify(match))); - } - return cb(null, match); - }, - (match, cb) => db.courts.findOne(court_q, (err, court) => cb(err, match, court)), - (match, court, cb) => { - if (court.match_id === match_id) { - cb(null, match, court, false); - return; - } - - db.courts.update(court_q, {$set: {match_id: match_id}}, {}, (err) => { - cb(err, match, court, true); - }); - }, - (match, court, changed_court, cb) => { - if (changed_court) { - admin.notify_change(req.app, tournament_key, 'court_current_match', { - match_id, - court_id: court._id, - }); - } - cb(null, match, changed_court); - }, - (match, changed_court, cb) => { - btp_manager.update_score(req.app, match); - - cb(null, match, changed_court); - }, - (match, changed_court, cb) => { - if (changed_court) { - ticker_manager.pushall(req.app, tournament_key); - } else { - ticker_manager.update_score(req.app, match); - } - - cb(); - }, - ], function(err) { - if (err) { - res.json({ - status: 'error', - message: err.message, - }); - return; - } - - admin.notify_change(req.app, tournament_key, 'score', { - match_id, - network_score: update.network_score, - team1_won: update.team1_won, - shuttle_count: update.shuttle_count, - }); - res.json({status: 'ok'}); - }); -} - -function logo_handler(req, res) { - const {tournament_key, logo_id} = req.params; - assert(tournament_key); - assert(logo_id); - const m = /^[-0-9a-f]+\.(gif|png|jpg|jpeg|svg|webp)$/.exec(logo_id); - assert(m, `Invalid logo ${logo_id}`); - const mime = { - gif: 'image/gif', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - svg: 'image/svg+xml', - webp: 'image/webp', - }[m[1]]; - assert(mime, `Unsupported ext ${JSON.stringify(m[1])}`); - - const fn = path.join(utils.root_dir(), 'data', 'logos', path.basename(logo_id)); - res.setHeader('Content-Type', mime); - res.setHeader('Cache-Control', 'public, max-age=31536000'); - res.sendFile(fn); -} module.exports = { - courts_handler, logo_handler, - matches_handler, - matchinfo_handler, - score_handler, + matchinfo_handler }; diff --git a/bts/match_automation.js b/bts/match_automation.js new file mode 100644 index 0000000..793fefa --- /dev/null +++ b/bts/match_automation.js @@ -0,0 +1,1397 @@ +'use strict'; + +const match_scoring = require('../static/js/match_scoring'); +const DEFAULT_PREPARATION_SUCCESSOR_RALLY_COUNT = 11; +const DEFAULT_NOW_FN = () => Date.now(); + +function fallback_scoring_format() { + return { + numSets: 3, + set_points: { + end_points: 21, + max_points: 30, + }, + last_set_points: { + end_points: 21, + max_points: 30, + }, + }; +} + +function normalize_scoring_format(scoring_format) { + const format = scoring_format || fallback_scoring_format(); + const fallback = fallback_scoring_format(); + return { + numSets: Number.isFinite(format.numSets) && format.numSets > 0 ? format.numSets : fallback.numSets, + set_points: { + end_points: Number.isFinite(format?.set_points?.end_points) ? format.set_points.end_points : fallback.set_points.end_points, + max_points: Number.isFinite(format?.set_points?.max_points) ? format.set_points.max_points : fallback.set_points.max_points, + }, + last_set_points: { + end_points: Number.isFinite(format?.last_set_points?.end_points) ? format.last_set_points.end_points : fallback.last_set_points.end_points, + max_points: Number.isFinite(format?.last_set_points?.max_points) ? format.last_set_points.max_points : fallback.last_set_points.max_points, + }, + }; +} + +function sets_needed_to_win(scoring_format) { + const format = normalize_scoring_format(scoring_format); + return Math.floor(format.numSets / 2) + 1; +} + +function get_set_points(scoring_format, set_index) { + const format = normalize_scoring_format(scoring_format); + const use_last_set_points = set_index === (format.numSets - 1); + return use_last_set_points ? format.last_set_points : format.set_points; +} + +function normalize_score_pair(score_pair) { + if (!Array.isArray(score_pair) || score_pair.length < 2) { + return null; + } + const score_a = Number(score_pair[0]); + const score_b = Number(score_pair[1]); + if (!Number.isFinite(score_a) || !Number.isFinite(score_b)) { + return null; + } + return [score_a, score_b]; +} + +function get_current_match_state(match, scoring_format) { + if (!match || !Array.isArray(match.network_score) || match.network_score.length === 0) { + return null; + } + + let wins_a = 0; + let wins_b = 0; + let current_set_score = null; + let current_set_index = 0; + + for (let idx = 0; idx < match.network_score.length; idx++) { + const score = normalize_score_pair(match.network_score[idx]); + if (!score) { + return null; + } + const set_points = get_set_points(scoring_format, idx); + if (match_scoring.is_set_over(score[0], score[1], set_points)) { + if (score[0] > score[1]) { + wins_a += 1; + } else if (score[1] > score[0]) { + wins_b += 1; + } else { + return null; + } + current_set_index = idx + 1; + current_set_score = [0, 0]; + continue; + } + + current_set_index = idx; + current_set_score = score; + return { + wins: [wins_a, wins_b], + current_set_index, + current_set_score, + }; + } + + if (!current_set_score) { + current_set_score = [0, 0]; + } + + return { + wins: [wins_a, wins_b], + current_set_index, + current_set_score, + }; +} + +function get_current_leader(score_pair) { + if (!score_pair) { + return null; + } + if (score_pair[0] > score_pair[1]) { + return 0; + } + if (score_pair[1] > score_pair[0]) { + return 1; + } + return null; +} + +function rallies_needed_for_set_win(score_pair, leader, set_points) { + for (let added = 0; added <= 100; added++) { + const score = [...score_pair]; + score[leader] += added; + if (match_scoring.is_set_over(score[0], score[1], set_points) && score[leader] > score[1 - leader]) { + return added; + } + } + return null; +} + +function can_leader_finish_match_within_rallies(match, scoring_format, rally_count) { + if (!match || !match.setup || !match.setup.now_on_court) { + return false; + } + if (match.team1_won !== undefined && match.team1_won !== null) { + return false; + } + if (!Number.isFinite(rally_count) || rally_count <= 0) { + return false; + } + + const state = get_current_match_state(match, scoring_format); + if (!state) { + return false; + } + + const leader = get_current_leader(state.current_set_score); + if (leader === null) { + return false; + } + + let remaining_rallies = rally_count; + let wins = [...state.wins]; + let current_set_index = state.current_set_index; + let current_set_score = [...state.current_set_score]; + const wins_needed = sets_needed_to_win(scoring_format); + + while (remaining_rallies > 0) { + if (wins[0] >= wins_needed || wins[1] >= wins_needed) { + return true; + } + + const set_points = get_set_points(scoring_format, current_set_index); + const rallies_needed = rallies_needed_for_set_win(current_set_score, leader, set_points); + if (!Number.isFinite(rallies_needed)) { + return false; + } + + if (remaining_rallies < rallies_needed) { + return false; + } + + current_set_score[leader] += rallies_needed; + remaining_rallies -= rallies_needed; + + if (!match_scoring.is_set_over(current_set_score[0], current_set_score[1], set_points)) { + return false; + } + + wins[leader] += 1; + if (wins[leader] >= wins_needed) { + return true; + } + + current_set_index += 1; + current_set_score = [0, 0]; + } + + return wins[0] >= wins_needed || wins[1] >= wins_needed; +} + +function calculate_location_preparation_need(tournament, location_id) { + const courts = Array.isArray(tournament?.courts) ? tournament.courts : []; + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const location_courts = courts.filter((court) => court && court.location_id === location_id); + const active_location_courts = location_courts.filter((court) => court && court.is_active === true); + const active_location_court_ids = new Set(active_location_courts.map((court) => court._id)); + const occupied_court_ids = new Set( + matches + .filter((match) => match && match.setup && match.setup.now_on_court === true && match.setup.court_id) + .map((match) => match.setup.court_id) + ); + + const successor_need_count = matches.filter((match) => { + const setup = match && match.setup; + if (!setup || !setup.now_on_court || !setup.needs_preparation_successor) { + return false; + } + return active_location_court_ids.has(setup.court_id); + }).length; + + const free_court_count = active_location_courts.filter((court) => { + return !occupied_court_ids.has(court._id); + }).length; + const active_court_count = active_location_courts.length; + const required_preparation_count = Math.min(active_court_count, successor_need_count + free_court_count); + + return { + location_id, + active_court_count, + successor_need_count, + free_court_count, + required_preparation_count, + }; +} + +function calculate_location_preparation_status(tournament, location_id) { + const need = calculate_location_preparation_need(tournament, location_id); + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const current_preparation_count = matches.filter((match) => { + const setup = match && match.setup; + return !!setup && setup.state === 'preparation' && setup.location_id === location_id; + }).length; + + return { + ...need, + current_preparation_count, + missing_preparation_count: Math.max(0, need.required_preparation_count - current_preparation_count), + }; +} + +function cmp_values(a, b) { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function cmp_scheduled_match_order(m1, m2) { + const setup1 = m1?.setup || {}; + const setup2 = m2?.setup || {}; + const time_str1 = setup1.scheduled_time_str; + const time_str2 = setup2.scheduled_time_str; + + if (time_str1 && !time_str2) return -1; + if (time_str2 && !time_str1) return 1; + + const date_cmp = cmp_values(setup1.scheduled_date, setup2.scheduled_date); + if (date_cmp !== 0) return date_cmp; + + if (time_str1 === '00:00' && time_str2 === '00:00') { + return cmp_values(setup1.match_num, setup2.match_num); + } + if (time_str1 === '00:00' && time_str2 !== '00:00') return 1; + if (time_str2 === '00:00' && time_str1 !== '00:00') return -1; + + const time_cmp = cmp_values(time_str1, time_str2); + if (time_cmp !== 0) return time_cmp; + + if (m1?.match_order !== undefined && m2?.match_order !== undefined) { + const match_order_cmp = cmp_values(m1.match_order, m2.match_order); + if (match_order_cmp !== 0) return match_order_cmp; + } + + return cmp_values(setup1.match_num, setup2.match_num); +} + +function get_courts_by_id(tournament) { + const map = new Map(); + const courts = Array.isArray(tournament?.courts) ? tournament.courts : []; + courts.forEach((court) => { + if (court && court._id) { + map.set(court._id, court); + } + }); + return map; +} + +function match_matches_location(match, location_id, courts_by_id) { + const setup = match?.setup || {}; + if (setup.location_id != null) { + return setup.location_id === location_id; + } + if (setup.court_id != null) { + const court = courts_by_id.get(setup.court_id); + if (!court) { + return true; + } + return court.location_id === location_id; + } + return true; +} + +function is_match_completely_initialized(match) { + const teams = match?.setup?.teams; + if (!Array.isArray(teams) || teams.length < 2) { + return false; + } + return teams.every((team) => Array.isArray(team?.players) && team.players.length > 0); +} + +function get_match_players(match) { + const teams = match?.setup?.teams; + if (!Array.isArray(teams)) { + return []; + } + return teams.flatMap((team) => Array.isArray(team?.players) ? team.players : []); +} + +function get_match_player_btp_ids(match) { + return get_match_players(match) + .map((player) => player?.btp_id) + .filter((btp_id) => btp_id != null); +} + +function is_finished_match(match) { + const state = match?.setup?.state; + if (state === 'finished') { + return true; + } + return match?.team1_won !== undefined && match?.team1_won !== null; +} + +function get_match_planning_ids(match) { + if (!Array.isArray(match?.btp_match_ids)) { + return []; + } + return match.btp_match_ids + .map((entry) => entry?.planning) + .filter((planning) => planning != null); +} + +function build_matches_by_planning_id(tournament) { + const map = new Map(); + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + matches.forEach((match) => { + get_match_planning_ids(match).forEach((planning_id) => { + if (!map.has(planning_id)) { + map.set(planning_id, []); + } + map.get(planning_id).push(match); + }); + }); + return map; +} + +function get_direct_predecessor_matches(match, tournament, options = {}) { + const links = match?.setup?.links || {}; + const planning_ids = [links.from1, links.from2].filter((planning_id) => planning_id != null); + if (planning_ids.length === 0) { + return []; + } + + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const predecessors = []; + planning_ids.forEach((planning_id) => { + const planning_matches = matches_by_planning_id.get(planning_id) || []; + planning_matches.forEach((candidate) => { + if (candidate && candidate._id !== match._id && !predecessors.includes(candidate)) { + predecessors.push(candidate); + } + }); + }); + return predecessors; +} + +function has_open_participant_dependency(match, tournament, options = {}) { + const participants_are_complete = is_match_completely_initialized(match); + const direct_predecessors = get_direct_predecessor_matches(match, tournament, options); + if (!participants_are_complete && direct_predecessors.some((predecessor) => !is_finished_match(predecessor))) { + return true; + } + if (!participants_are_complete && direct_predecessors.length === 0) { + const links = match?.setup?.links || {}; + if (links.from1 != null || links.from2 != null || links.from1_link != null || links.from2_link != null) { + return true; + } + } + + const current_player_ids = new Set(get_match_player_btp_ids(match)); + if (current_player_ids.size === 0) { + return false; + } + + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + return matches.some((other_match) => { + if (!other_match || other_match._id === match._id) { + return false; + } + if (is_finished_match(other_match)) { + return false; + } + if (cmp_scheduled_match_order(other_match, match) >= 0) { + return false; + } + return get_match_player_btp_ids(other_match).some((btp_id) => current_player_ids.has(btp_id)); + }); +} + +function get_time_limit_before_scheduled_minutes(tournament) { + if (tournament?.preparation_call_time_limit_before_scheduled_enabled !== true) { + return null; + } + const minutes = Number(tournament?.preparation_call_time_limit_before_scheduled_minutes); + return Number.isFinite(minutes) && minutes >= 0 ? minutes : null; +} + +function get_time_limit_before_scheduled_minutes_for_prefix(tournament, prefix) { + if (prefix === 'preparation_call') { + return get_time_limit_before_scheduled_minutes(tournament); + } + if (tournament?.[`${prefix}_time_limit_before_scheduled_enabled`] !== true) { + return null; + } + const minutes = Number(tournament?.[`${prefix}_time_limit_before_scheduled_minutes`]); + return Number.isFinite(minutes) && minutes >= 0 ? minutes : null; +} + +function get_scheduled_timestamp(match) { + const setup = match?.setup || {}; + if (!setup.scheduled_date || !setup.scheduled_time_str) { + return null; + } + const timestamp = Date.parse(`${setup.scheduled_date}T${setup.scheduled_time_str}:00`); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function passes_time_limit_before_scheduled(match, tournament, now_ts = DEFAULT_NOW_FN()) { + const limit_minutes = get_time_limit_before_scheduled_minutes(tournament); + if (limit_minutes == null) { + return true; + } + const scheduled_ts = get_scheduled_timestamp(match); + if (!Number.isFinite(scheduled_ts)) { + return false; + } + return now_ts >= (scheduled_ts - (limit_minutes * 60 * 1000)); +} + +function passes_time_limit_before_scheduled_for_prefix(match, tournament, prefix, now_ts = DEFAULT_NOW_FN()) { + const limit_minutes = get_time_limit_before_scheduled_minutes_for_prefix(tournament, prefix); + if (limit_minutes == null) { + return true; + } + const scheduled_ts = get_scheduled_timestamp(match); + if (!Number.isFinite(scheduled_ts)) { + return false; + } + return now_ts >= (scheduled_ts - (limit_minutes * 60 * 1000)); +} + +function get_participant_readiness_mode(tournament, prefix = 'preparation_call') { + const field = `${prefix}_participant_readiness_mode`; + const value = tournament?.[field]; + if (value === 'checked_in' || value === 'pause_expired' || value === 'disabled') { + return value; + } + if (prefix === 'preparation_call' && tournament?.preparation_call_player_pause_expired_enabled === true) { + return 'pause_expired'; + } + if (prefix === 'call_on_court' && tournament?.call_on_court_player_pause_expired_enabled === true) { + return 'pause_expired'; + } + return 'disabled'; +} + +function passes_player_pause_expired_rule(match, tournament, now_ts = DEFAULT_NOW_FN()) { + return passes_player_pause_expired_rule_for_prefix(match, tournament, 'preparation_call', now_ts); +} + +function passes_player_pause_expired_rule_for_prefix(match, tournament, prefix, now_ts = DEFAULT_NOW_FN()) { + if (get_participant_readiness_mode(tournament, prefix) !== 'pause_expired') { + return true; + } + + const pause_duration_ms = Number(tournament?.btp_settings?.pause_duration_ms); + if (!Number.isFinite(pause_duration_ms) || pause_duration_ms <= 0) { + return true; + } + + return get_match_players(match).every((player) => { + if (!player) { + return true; + } + if (player.now_playing_on_court || player.now_tablet_on_court) { + return false; + } + if (!player.last_time_on_court_ts) { + return true; + } + return (now_ts - Number(player.last_time_on_court_ts)) >= pause_duration_ms; + }); +} + +function passes_players_checked_in_rule(match, tournament) { + return passes_players_checked_in_rule_for_prefix(match, tournament, 'preparation_call'); +} + +function passes_players_checked_in_rule_for_prefix(match, tournament, prefix) { + if (get_participant_readiness_mode(tournament, prefix) !== 'checked_in') { + return true; + } + return get_match_players(match).every((player) => !player || player.checked_in === true); +} + +function get_waiting_technical_official_ids(tournament) { + const officials = Array.isArray(tournament?.umpires) ? tournament.umpires : []; + const umpire_ids = new Set(); + const service_judge_ids = new Set(); + + officials.forEach((official) => { + if (!official || !official._id) { + return; + } + if (official.umpire_wait != null) { + umpire_ids.add(official._id); + } + if (official.service_judge_wait != null) { + service_judge_ids.add(official._id); + } + }); + + return { umpire_ids, service_judge_ids }; +} + +function passes_technical_officials_available_rule(match, tournament) { + if (tournament?.preparation_call_technical_officials_available_enabled !== true) { + return true; + } + return passes_technical_officials_available_rule_for_prefix(match, tournament, 'preparation_call'); +} + +function passes_technical_officials_available_rule_for_prefix(match, tournament, prefix) { + if (prefix !== 'preparation_call' && prefix !== 'call_on_court') { + return true; + } + if (prefix === 'call_on_court' && tournament?.call_on_court_technical_officials_mode !== 'available') { + return true; + } + + const official_rotation_mode = tournament?.official_rotation_mode || 'umpire_and_service_judge'; + if (official_rotation_mode === 'disabled') { + return true; + } + const technical_official_auto_assignment_mode = tournament?.technical_official_auto_assignment_mode || 'manual_only'; + if (technical_official_auto_assignment_mode !== 'when_available' && technical_official_auto_assignment_mode !== 'on_preparation_call') { + return true; + } + + const setup = match?.setup || {}; + const has_assigned_umpire = !!setup.umpire; + const has_assigned_service_judge = !!setup.service_judge; + const assigned_official_ids = new Set( + [setup.umpire?._id, setup.service_judge?._id].filter((value) => !!value) + ); + + const { umpire_ids, service_judge_ids } = get_waiting_technical_official_ids(tournament); + const available_umpire_ids = new Set([...umpire_ids].filter((official_id) => !assigned_official_ids.has(official_id))); + const available_service_judge_ids = new Set([...service_judge_ids].filter((official_id) => !assigned_official_ids.has(official_id))); + if (official_rotation_mode === 'umpire_only') { + return has_assigned_umpire || available_umpire_ids.size > 0; + } + if (has_assigned_umpire && has_assigned_service_judge) { + return true; + } + + const needs_umpire = !has_assigned_umpire; + const needs_service_judge = !has_assigned_service_judge; + + if (needs_umpire && !needs_service_judge) { + return available_umpire_ids.size > 0; + } + if (!needs_umpire && needs_service_judge) { + return available_service_judge_ids.size > 0; + } + if (!needs_umpire && !needs_service_judge) { + return true; + } + + if (available_umpire_ids.size === 0 || available_service_judge_ids.size === 0) { + return false; + } + + for (const umpire_id of available_umpire_ids) { + for (const service_judge_id of available_service_judge_ids) { + if (umpire_id !== service_judge_id) { + return true; + } + } + } + + return false; +} + +function get_call_on_court_required_technical_official_roles(tournament, court) { + const official_rotation_mode = tournament?.official_rotation_mode || 'umpire_and_service_judge'; + const court_supports_umpire = !!court && court.has_umpire !== false; + const court_supports_service_judge = court_supports_umpire && !!court && court.has_service_judge !== false; + + if (official_rotation_mode === 'disabled') { + return { + needs_umpire: false, + needs_service_judge: false, + }; + } + + if (official_rotation_mode === 'umpire_only') { + return { + needs_umpire: true, + needs_service_judge: false, + }; + } + + return { + needs_umpire: true, + needs_service_judge: court_supports_service_judge, + }; +} + +function passes_call_on_court_technical_officials_available_rule(match, court, tournament) { + if (tournament?.call_on_court_technical_officials_mode !== 'available') { + return true; + } + if (!court) { + return false; + } + + const technical_official_auto_assignment_mode = tournament?.technical_official_auto_assignment_mode || 'manual_only'; + if ( + technical_official_auto_assignment_mode !== 'when_available' && + technical_official_auto_assignment_mode !== 'on_preparation_call' && + technical_official_auto_assignment_mode !== 'on_match_call_if_possible' + ) { + return true; + } + + const { needs_umpire, needs_service_judge } = get_call_on_court_required_technical_official_roles(tournament, court); + const setup = match?.setup || {}; + const assigned_official_ids = new Set( + [setup.umpire?._id, setup.service_judge?._id].filter((value) => !!value) + ); + const { umpire_ids, service_judge_ids } = get_waiting_technical_official_ids(tournament); + const available_umpire_ids = new Set([...umpire_ids].filter((official_id) => !assigned_official_ids.has(official_id))); + const available_service_judge_ids = new Set([...service_judge_ids].filter((official_id) => !assigned_official_ids.has(official_id))); + + if (!needs_umpire && !needs_service_judge) { + return true; + } + + const has_assigned_umpire = !needs_umpire || !!setup.umpire; + const has_assigned_service_judge = !needs_service_judge || !!setup.service_judge; + + if (has_assigned_umpire && has_assigned_service_judge) { + return true; + } + if (!has_assigned_umpire && has_assigned_service_judge) { + return available_umpire_ids.size > 0; + } + if (has_assigned_umpire && !has_assigned_service_judge) { + return available_service_judge_ids.size > 0; + } + + if (available_umpire_ids.size === 0) { + return false; + } + if (!needs_service_judge) { + return true; + } + if (available_service_judge_ids.size === 0) { + return false; + } + + for (const umpire_id of available_umpire_ids) { + for (const service_judge_id of available_service_judge_ids) { + if (umpire_id !== service_judge_id) { + return true; + } + } + } + + return false; +} + +function passes_call_on_court_technical_official_assignment_possible_rule(match, court, tournament) { + if (tournament?.call_on_court_technical_officials_mode !== 'available') { + return true; + } + if (!court) { + return false; + } + const official_rotation_mode = tournament?.official_rotation_mode || 'umpire_and_service_judge'; + if (official_rotation_mode === 'disabled') { + return true; + } + const setup = match?.setup || {}; + const { needs_umpire: role_needs_umpire, needs_service_judge: role_needs_service_judge } = + get_call_on_court_required_technical_official_roles(tournament, court); + const needs_umpire = role_needs_umpire && !setup.umpire; + const needs_service_judge = role_needs_service_judge && !setup.service_judge; + const requires_umpire_space = !!setup.umpire || needs_umpire; + const requires_service_judge_space = !!setup.service_judge || needs_service_judge; + + if (requires_umpire_space && court.has_umpire === false) { + return false; + } + if (requires_service_judge_space && court.has_service_judge === false) { + return false; + } + return true; +} + +function passes_base_preparation_rules(match, location_id, tournament, options = {}) { + const setup = match?.setup || {}; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const ignore_location = options.ignore_location === true; + const ignore_technical_officials_available_rule = options.ignore_technical_officials_available_rule === true; + + if (setup.state !== 'scheduled') return false; + if (setup.is_match !== true) return false; + if (setup.incomplete === true) return false; + if (!is_match_completely_initialized(match)) return false; + if (match?.team1_won !== undefined && match?.team1_won !== null) return false; + if (setup.state === 'preparation' || setup.state === 'oncourt' || setup.state === 'blocked' || setup.state === 'finished') return false; + if (!ignore_location && !match_matches_location(match, location_id, courts_by_id)) return false; + if (has_open_participant_dependency(match, tournament, { matches_by_planning_id })) return false; + if (!passes_time_limit_before_scheduled(match, tournament, now_ts)) return false; + if (!passes_player_pause_expired_rule(match, tournament, now_ts)) return false; + if (!ignore_technical_officials_available_rule && !passes_technical_officials_available_rule(match, tournament)) return false; + + return true; +} + +function get_frontier_block_limit(tournament) { + if (tournament?.preparation_call_block_ahead_limit_enabled !== true) { + return null; + } + const limit = Number(tournament?.preparation_call_block_ahead_limit); + return Number.isFinite(limit) && limit >= 0 ? limit : null; +} + +function get_frontier_block_limit_for_prefix(tournament, prefix) { + if (prefix === 'preparation_call') { + return get_frontier_block_limit(tournament); + } + if (tournament?.[`${prefix}_block_ahead_limit_enabled`] !== true) { + return null; + } + const limit = Number(tournament?.[`${prefix}_block_ahead_limit`]); + return Number.isFinite(limit) && limit >= 0 ? limit : null; +} + +function get_frontier_time_limit_minutes(tournament) { + if (tournament?.preparation_call_time_ahead_of_frontier_enabled !== true) { + return null; + } + const minutes = Number(tournament?.preparation_call_time_ahead_of_frontier_minutes); + return Number.isFinite(minutes) && minutes >= 0 ? minutes : null; +} + +function get_frontier_time_limit_minutes_for_prefix(tournament, prefix) { + if (prefix === 'preparation_call') { + return get_frontier_time_limit_minutes(tournament); + } + if (tournament?.[`${prefix}_time_ahead_of_frontier_enabled`] !== true) { + return null; + } + const minutes = Number(tournament?.[`${prefix}_time_ahead_of_frontier_minutes`]); + return Number.isFinite(minutes) && minutes >= 0 ? minutes : null; +} + +function get_frontier_match_limit(tournament) { + if (tournament?.preparation_call_matches_ahead_of_frontier_enabled !== true) { + return null; + } + const limit = Number(tournament?.preparation_call_matches_ahead_of_frontier_limit); + return Number.isFinite(limit) && limit >= 0 ? limit : null; +} + +function get_frontier_match_limit_for_prefix(tournament, prefix) { + if (prefix === 'preparation_call') { + return get_frontier_match_limit(tournament); + } + if (tournament?.[`${prefix}_matches_ahead_of_frontier_enabled`] !== true) { + return null; + } + const limit = Number(tournament?.[`${prefix}_matches_ahead_of_frontier_limit`]); + return Number.isFinite(limit) && limit >= 0 ? limit : null; +} + +function get_location_relevant_matches(tournament, location_id, options = {}) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + return matches + .filter((match) => match?.setup?.state === 'scheduled') + .filter((match) => match_matches_location(match, location_id, courts_by_id)) + .sort(cmp_scheduled_match_order); +} + +function find_preparation_frontier_match(tournament, location_id, options = {}) { + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_location_relevant_matches(tournament, location_id, { courts_by_id }); + + return relevant_matches.find((match) => !passes_base_preparation_rules(match, location_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + })) || null; +} + +function build_frontier_block_sequence(matches) { + const block_sequence = []; + let last_block_key = null; + matches.forEach((match) => { + const setup = match?.setup || {}; + const block_key = `${setup.event_name || ''}|${setup.phase_block_key || 'UNKNOWN'}`; + if (block_key !== last_block_key) { + block_sequence.push(block_key); + last_block_key = block_key; + } + }); + return block_sequence; +} + +function get_frontier_block_distance(match, frontier, relevant_matches) { + const sequence = build_frontier_block_sequence(relevant_matches); + const match_key = `${match?.setup?.event_name || ''}|${match?.setup?.phase_block_key || 'UNKNOWN'}`; + const frontier_key = `${frontier?.setup?.event_name || ''}|${frontier?.setup?.phase_block_key || 'UNKNOWN'}`; + const match_index = sequence.indexOf(match_key); + const frontier_index = sequence.indexOf(frontier_key); + if (match_index < 0 || frontier_index < 0) { + return Number.POSITIVE_INFINITY; + } + return match_index - frontier_index; +} + +function passes_frontier_block_limit(match, frontier, tournament, relevant_matches) { + const limit = get_frontier_block_limit(tournament); + if (limit == null || !frontier) { + return true; + } + return get_frontier_block_distance(match, frontier, relevant_matches) <= limit; +} + +function passes_frontier_block_limit_for_prefix(match, frontier, tournament, relevant_matches, prefix) { + const limit = get_frontier_block_limit_for_prefix(tournament, prefix); + if (limit == null || !frontier) { + return true; + } + return get_frontier_block_distance(match, frontier, relevant_matches) <= limit; +} + +function passes_frontier_time_limit(match, frontier, tournament) { + const limit_minutes = get_frontier_time_limit_minutes(tournament); + if (limit_minutes == null || !frontier) { + return true; + } + const match_ts = get_scheduled_timestamp(match); + const frontier_ts = get_scheduled_timestamp(frontier); + if (!Number.isFinite(match_ts) || !Number.isFinite(frontier_ts)) { + return false; + } + return (match_ts - frontier_ts) <= (limit_minutes * 60 * 1000); +} + +function passes_frontier_time_limit_for_prefix(match, frontier, tournament, prefix) { + const limit_minutes = get_frontier_time_limit_minutes_for_prefix(tournament, prefix); + if (limit_minutes == null || !frontier) { + return true; + } + const match_ts = get_scheduled_timestamp(match); + const frontier_ts = get_scheduled_timestamp(frontier); + if (!Number.isFinite(match_ts) || !Number.isFinite(frontier_ts)) { + return false; + } + return (match_ts - frontier_ts) <= (limit_minutes * 60 * 1000); +} + +function passes_frontier_match_limit(match, frontier, tournament, relevant_matches) { + const limit = get_frontier_match_limit(tournament); + if (limit == null || !frontier) { + return true; + } + const match_index = relevant_matches.findIndex((candidate) => candidate?._id === match?._id); + const frontier_index = relevant_matches.findIndex((candidate) => candidate?._id === frontier?._id); + if (match_index < 0 || frontier_index < 0) { + return false; + } + return (match_index - frontier_index) <= limit; +} + +function passes_frontier_match_limit_for_prefix(match, frontier, tournament, relevant_matches, prefix) { + const limit = get_frontier_match_limit_for_prefix(tournament, prefix); + if (limit == null || !frontier) { + return true; + } + const match_index = relevant_matches.findIndex((candidate) => candidate?._id === match?._id); + const frontier_index = relevant_matches.findIndex((candidate) => candidate?._id === frontier?._id); + if (match_index < 0 || frontier_index < 0) { + return false; + } + return (match_index - frontier_index) <= limit; +} + +function get_call_on_court_relevant_matches(tournament, location_id, options = {}) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const only_preparation = tournament?.call_on_court_only_preparation_enabled === true; + return matches + .filter((match) => { + const state = match?.setup?.state; + if (only_preparation) { + return state === 'preparation'; + } + return state === 'scheduled' || state === 'preparation'; + }) + .filter((match) => match_matches_location(match, location_id, courts_by_id)) + .sort(cmp_scheduled_match_order); +} + +function get_call_on_court_preparation_age_minutes(tournament) { + if (tournament?.call_on_court_only_preparation_enabled !== true) { + return null; + } + const minutes = Number(tournament?.call_on_court_only_preparation_minutes); + return Number.isFinite(minutes) && minutes >= 0 ? minutes : 0; +} + +function passes_call_on_court_preparation_rule(match, tournament, now_ts = DEFAULT_NOW_FN()) { + const minimum_minutes = get_call_on_court_preparation_age_minutes(tournament); + if (minimum_minutes == null) { + return true; + } + if (match?.setup?.state !== 'preparation') { + return false; + } + const preparation_ts = Number(match?.setup?.preparation_call_timestamp); + if (!Number.isFinite(preparation_ts)) { + return false; + } + return (now_ts - preparation_ts) >= (minimum_minutes * 60 * 1000); +} + +function get_required_technical_official_roles(tournament) { + const official_rotation_mode = tournament?.official_rotation_mode || 'umpire_and_service_judge'; + return { + needs_umpire: official_rotation_mode !== 'disabled', + needs_service_judge: official_rotation_mode === 'umpire_and_service_judge', + }; +} + +function passes_call_on_court_technical_officials_checked_in_rule(match, court, tournament) { + const mode = tournament?.call_on_court_technical_officials_mode || 'disabled'; + if (mode !== 'checked_in') { + return true; + } + const { needs_umpire, needs_service_judge } = get_call_on_court_required_technical_official_roles(tournament, court); + const setup = match?.setup || {}; + if (needs_umpire && setup.umpire?.checked_in !== true) { + return false; + } + if (needs_service_judge && setup.service_judge?.checked_in !== true) { + return false; + } + return true; +} + +function passes_call_on_court_assigned_official_space_rule(match, court, tournament) { + if (tournament?.call_on_court_require_official_space_enabled !== true) { + return true; + } + if (!court) { + return false; + } + const setup = match?.setup || {}; + if (setup.umpire && court.has_umpire === false) { + return false; + } + if (setup.service_judge && court.has_service_judge === false) { + return false; + } + return true; +} + +function passes_base_call_on_court_rules(match, court_id, tournament, options = {}) { + const setup = match?.setup || {}; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const court = courts_by_id.get(court_id) || null; + const location_id = court?.location_id || null; + + if (!court || court.is_active !== true) return false; + if (setup.state !== 'scheduled' && setup.state !== 'preparation') return false; + if (setup.is_match !== true) return false; + if (setup.incomplete === true) return false; + if (!is_match_completely_initialized(match)) return false; + if (setup.now_on_court === true) return false; + if (match?.team1_won !== undefined && match?.team1_won !== null) return false; + if (!match_matches_location(match, location_id, courts_by_id)) return false; + if (has_open_participant_dependency(match, tournament, { matches_by_planning_id })) return false; + if (!passes_call_on_court_preparation_rule(match, tournament, now_ts)) return false; + if (!passes_time_limit_before_scheduled_for_prefix(match, tournament, 'call_on_court', now_ts)) return false; + if (!passes_players_checked_in_rule_for_prefix(match, tournament, 'call_on_court')) return false; + if (!passes_player_pause_expired_rule_for_prefix(match, tournament, 'call_on_court', now_ts)) return false; + if (!passes_call_on_court_technical_officials_checked_in_rule(match, court, tournament)) return false; + if (!passes_call_on_court_technical_officials_available_rule(match, court, tournament)) return false; + if (!passes_call_on_court_technical_official_assignment_possible_rule(match, court, tournament)) return false; + if (!passes_call_on_court_assigned_official_space_rule(match, court, tournament)) return false; + + return true; +} + +function find_call_on_court_frontier_match(tournament, court_id, options = {}) { + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const court = courts_by_id.get(court_id) || null; + const location_id = court?.location_id || null; + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_call_on_court_relevant_matches(tournament, location_id, { courts_by_id }); + + return relevant_matches.find((match) => !passes_base_call_on_court_rules(match, court_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + })) || null; +} + +function is_match_eligible_for_on_court_call(match, court_id, tournament, options = {}) { + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const court = courts_by_id.get(court_id) || null; + const location_id = court?.location_id || null; + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_call_on_court_relevant_matches(tournament, location_id, { courts_by_id }); + const frontier = options.frontier !== undefined + ? options.frontier + : find_call_on_court_frontier_match(tournament, court_id, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + }); + + if (!passes_base_call_on_court_rules(match, court_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + })) { + return false; + } + if (frontier && cmp_scheduled_match_order(match, frontier) < 0) { + return true; + } + if (!passes_frontier_block_limit_for_prefix(match, frontier, tournament, relevant_matches, 'call_on_court')) return false; + if (!passes_frontier_time_limit_for_prefix(match, frontier, tournament, 'call_on_court')) return false; + if (!passes_frontier_match_limit_for_prefix(match, frontier, tournament, relevant_matches, 'call_on_court')) return false; + + return true; +} + +function cmp_call_on_court_candidate_order(m1, m2) { + const state1 = m1?.setup?.state; + const state2 = m2?.setup?.state; + if (state1 === 'preparation' && state2 !== 'preparation') return -1; + if (state2 === 'preparation' && state1 !== 'preparation') return 1; + const preparation_age_cmp = cmp_values( + Number(m1?.setup?.preparation_call_timestamp) || 0, + Number(m2?.setup?.preparation_call_timestamp) || 0 + ); + if (state1 === 'preparation' && state2 === 'preparation' && preparation_age_cmp !== 0) { + return preparation_age_cmp; + } + return cmp_scheduled_match_order(m1, m2); +} + +function find_call_on_court_candidates(tournament, court_id, options = {}) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const court = courts_by_id.get(court_id) || null; + const location_id = court?.location_id || null; + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_call_on_court_relevant_matches(tournament, location_id, { courts_by_id }); + const frontier = options.frontier !== undefined + ? options.frontier + : find_call_on_court_frontier_match(tournament, court_id, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + }); + + return matches + .filter((match) => is_match_eligible_for_on_court_call(match, court_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + frontier, + })) + .sort(cmp_call_on_court_candidate_order); +} + +function is_match_eligible_for_preparation(match, location_id, tournament, options = {}) { + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_location_relevant_matches(tournament, location_id, { courts_by_id }); + const frontier = options.frontier !== undefined + ? options.frontier + : find_preparation_frontier_match(tournament, location_id, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + }); + + if (!passes_base_preparation_rules(match, location_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + })) { + return false; + } + if (frontier && cmp_scheduled_match_order(match, frontier) < 0) { + return true; + } + if (!passes_frontier_block_limit(match, frontier, tournament, relevant_matches)) return false; + if (!passes_frontier_time_limit(match, frontier, tournament)) return false; + if (!passes_frontier_match_limit(match, frontier, tournament, relevant_matches)) return false; + + return true; +} + +function find_location_preparation_candidates(tournament, location_id, options = {}) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_location_relevant_matches(tournament, location_id, { courts_by_id }); + const frontier = options.frontier !== undefined + ? options.frontier + : find_preparation_frontier_match(tournament, location_id, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + }); + + return matches + .filter((match) => is_match_eligible_for_preparation(match, location_id, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + frontier, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + })) + .sort(cmp_scheduled_match_order); +} + +function get_global_relevant_matches(tournament) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + return matches + .filter((match) => match?.setup?.state === 'scheduled') + .sort(cmp_scheduled_match_order); +} + +function find_global_preparation_frontier_match(tournament, options = {}) { + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_global_relevant_matches(tournament); + + return relevant_matches.find((match) => !passes_base_preparation_rules(match, null, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + ignore_location: true, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + })) || null; +} + +function find_global_preparation_candidates(tournament, options = {}) { + const matches = Array.isArray(tournament?.matches) ? tournament.matches : []; + const courts_by_id = options.courts_by_id || get_courts_by_id(tournament); + const matches_by_planning_id = options.matches_by_planning_id || build_matches_by_planning_id(tournament); + const now_ts = options.now_ts != null ? options.now_ts : DEFAULT_NOW_FN(); + const relevant_matches = options.relevant_matches || get_global_relevant_matches(tournament); + const frontier = options.frontier !== undefined + ? options.frontier + : find_global_preparation_frontier_match(tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + relevant_matches, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + }); + + return matches + .filter((match) => { + if (!passes_base_preparation_rules(match, null, tournament, { + courts_by_id, + matches_by_planning_id, + now_ts, + ignore_location: true, + ignore_technical_officials_available_rule: options.ignore_technical_officials_available_rule === true, + })) { + return false; + } + if (frontier && cmp_scheduled_match_order(match, frontier) < 0) { + return true; + } + if (!passes_frontier_block_limit(match, frontier, tournament, relevant_matches)) return false; + if (!passes_frontier_time_limit(match, frontier, tournament)) return false; + if (!passes_frontier_match_limit(match, frontier, tournament, relevant_matches)) return false; + return true; + }) + .sort(cmp_scheduled_match_order); +} + +function calculate_location_preparation_selection(tournament, location_id, options = {}) { + const status = calculate_location_preparation_status(tournament, location_id); + const candidates = find_location_preparation_candidates(tournament, location_id, options); + const effective_required_preparation_count = + (tournament?.call_preparation_matches_automatically_enabled ? status.successor_need_count : 0); + const effective_missing_preparation_count = Math.max(0, effective_required_preparation_count - status.current_preparation_count); + const selected_matches = candidates.slice(0, status.missing_preparation_count); + const auto_selected_matches = candidates.slice(0, effective_missing_preparation_count); + + return { + location_id, + ...status, + effective_required_preparation_count, + effective_missing_preparation_count, + candidates, + selected_matches, + auto_selected_matches, + }; +} + +function get_preparation_successor_rally_count(tournament) { + const rally_count = Number(tournament && tournament.preparation_successor_rally_count); + if (Number.isFinite(rally_count) && rally_count > 0) { + return Math.floor(rally_count); + } + return DEFAULT_PREPARATION_SUCCESSOR_RALLY_COUNT; +} + +function calculate_preparation_successor_state(match, tournament) { + const rally_count = get_preparation_successor_rally_count(tournament); + const needs_preparation_successor = can_leader_finish_match_within_rallies( + match, + match && match.setup ? match.setup.scoring_format : null, + rally_count + ); + return { + needs_preparation_successor, + needs_preparation_successor_ts: needs_preparation_successor + ? (match?.setup?.needs_preparation_successor_ts || Date.now()) + : null, + rally_count, + }; +} + +async function fetch_location_preparation_status(app, tournament_key, location_id) { + const [courts, matches] = await Promise.all([ + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + ]); + + return calculate_location_preparation_status({ + courts, + matches, + }, location_id); +} + +async function fetch_location_preparation_selection(app, tournament_key, location_id, options = {}) { + const [tournament, locations, courts, matches, umpires] = await Promise.all([ + app.db.tournaments.findOne_async({ key: tournament_key }), + app.db.locations.find_async({ tournament_key }), + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + app.db.umpires.find_async({ tournament_key }), + ]); + + const location = (locations || []).find((entry) => entry && entry._id === location_id) || null; + const selection = calculate_location_preparation_selection({ + ...(tournament || {}), + locations, + courts, + matches, + umpires, + }, location_id, options); + + return { + ...selection, + location, + }; +} + +async function fetch_all_location_preparation_selections(app, tournament_key, options = {}) { + const [tournament, locations, courts, matches, umpires] = await Promise.all([ + app.db.tournaments.findOne_async({ key: tournament_key }), + app.db.locations.find_async({ tournament_key }), + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + app.db.umpires.find_async({ tournament_key }), + ]); + + return (locations || []).map((location) => { + const selection = calculate_location_preparation_selection({ + ...(tournament || {}), + locations, + courts, + matches, + umpires, + }, location._id, options); + + return { + ...selection, + location, + }; + }); +} + +async function fetch_global_preparation_candidates(app, tournament_key, options = {}) { + const [tournament, locations, courts, matches, umpires] = await Promise.all([ + app.db.tournaments.findOne_async({ key: tournament_key }), + app.db.locations.find_async({ tournament_key }), + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + app.db.umpires.find_async({ tournament_key }), + ]); + + return find_global_preparation_candidates({ + ...(tournament || {}), + locations, + courts, + matches, + umpires, + }, options); +} + +module.exports = { + can_leader_finish_match_within_rallies, + calculate_location_preparation_need, + calculate_location_preparation_selection, + calculate_location_preparation_status, + calculate_preparation_successor_state, + fetch_all_location_preparation_selections, + fetch_global_preparation_candidates, + fetch_location_preparation_selection, + fetch_location_preparation_status, + find_call_on_court_candidates, + find_global_preparation_candidates, + find_location_preparation_candidates, + find_call_on_court_frontier_match, + get_preparation_successor_rally_count, + get_current_match_state, + get_current_leader, + has_open_participant_dependency, + find_preparation_frontier_match, + get_participant_readiness_mode, + is_match_eligible_for_on_court_call, + is_match_eligible_for_preparation, + passes_call_on_court_assigned_official_space_rule, + passes_call_on_court_technical_official_assignment_possible_rule, + passes_call_on_court_preparation_rule, + passes_call_on_court_technical_officials_checked_in_rule, + passes_players_checked_in_rule, + passes_player_pause_expired_rule, + passes_technical_officials_available_rule, + rallies_needed_for_set_win, +}; diff --git a/bts/match_utils.js b/bts/match_utils.js new file mode 100644 index 0000000..b461bbb --- /dev/null +++ b/bts/match_utils.js @@ -0,0 +1,2273 @@ +'use_strict'; + +const assert = require('assert'); +const async = require('async'); +const update_queue = require('./update_queue'); + +const pending_preparation_selection_runs = new Map(); +const pending_technical_official_assignment_runs = new Map(); +const pending_technical_official_pause_runs = new Map(); +let technical_official_pause_interval = null; + +function is_tournament_automation_enabled(tournament) { + return tournament?.automation_enabled !== false; +} + +function get_court_sort_value(court) { + const numeric_num = Number(court?.num); + if (Number.isFinite(numeric_num)) { + return { numeric: true, value: numeric_num }; + } + return { numeric: false, value: String(court?.num || '') }; +} + +function cmp_court_order(c1, c2) { + const a = get_court_sort_value(c1); + const b = get_court_sort_value(c2); + if (a.numeric && b.numeric) { + return a.value - b.value; + } + return String(a.value).localeCompare(String(b.value), 'de', { numeric: true, sensitivity: 'base' }); +} + +function sort_free_courts_for_auto_call(courts, tabletoperators, tournament) { + const free_courts = Array.isArray(courts) ? [...courts] : []; + if (tournament?.tabletoperator_enabled !== true) { + return free_courts.sort(cmp_court_order); + } + + const free_court_ids = new Set(free_courts.map((court) => court?._id).filter(Boolean)); + const preferred_court_index = new Map(); + + (Array.isArray(tabletoperators) ? [...tabletoperators] : []) + .filter((tabletoperator) => tabletoperator && tabletoperator.court == null) + .sort((a, b) => Number(a.start_ts || 0) - Number(b.start_ts || 0)) + .forEach((tabletoperator, index) => { + const played_on_court = tabletoperator?.played_on_court; + if (!played_on_court || !free_court_ids.has(played_on_court) || preferred_court_index.has(played_on_court)) { + return; + } + preferred_court_index.set(played_on_court, index); + }); + + return free_courts.sort((c1, c2) => { + const preference1 = preferred_court_index.has(c1?._id) ? preferred_court_index.get(c1._id) : Number.POSITIVE_INFINITY; + const preference2 = preferred_court_index.has(c2?._id) ? preferred_court_index.get(c2._id) : Number.POSITIVE_INFINITY; + if (preference1 !== preference2) { + return preference1 - preference2; + } + return cmp_court_order(c1, c2); + }); +} + +function get_technical_official_break_after_assignment_ms(tournament) { + const seconds = Number(tournament?.technical_official_break_after_assignment_seconds); + if (!Number.isFinite(seconds) || seconds <= 0) { + return 0; + } + return Math.round(seconds * 1000); +} + +function is_technical_official_unavailable(official) { + if (!official) { + return true; + } + return official.umpire_pause != null || + official.service_judge_pause != null || + official.umpire_manual_pause != null || + official.service_judge_manual_pause != null || + official.inactive_list != null; +} + +function get_effective_technical_official_checked_in(official, tournament_or_btp_settings) { + if (!official) { + return false; + } + const btp_settings = tournament_or_btp_settings?.btp_settings || tournament_or_btp_settings || {}; + if (btp_settings.check_in_per_match === false) { + return !is_technical_official_unavailable(official); + } + return !!official.checked_in; +} + +function sync_technical_official_checked_in(official, tournament_or_btp_settings) { + if (!official) { + return official; + } + official.checked_in = get_effective_technical_official_checked_in(official, tournament_or_btp_settings); + return official; +} + +function match_needs_technical_official_assignment(match, tournament) { + if (!match || !match.setup) { + return false; + } + if ((tournament?.official_rotation_mode || 'umpire_and_service_judge') === 'disabled') { + return false; + } + if (!match.setup.umpire) { + return true; + } + if ((tournament?.official_rotation_mode || 'umpire_and_service_judge') !== 'umpire_and_service_judge') { + return false; + } + return !match.setup.service_judge; +} + +async function match_update(app, match, old_court, callback) { + async.waterfall([ + (wcb) => update_match_btp(app, match, wcb), + (wcb) => update_match_db(app, match, wcb), + (wcb) => notify_change_match_edit(app, match, wcb), + (wcb) => notify_bupws(app, match, old_court, wcb), + ], + (err) => { + return callback(err); + } + ); +} + +async function uncall_match(app, tournament, match, old_court, callback) { + // Imports + + // Requrements + + async.waterfall([ (wcb) => remove_called_timestamp(match, wcb), + (wcb) => remove_tablet_on_court(app, tournament.key, match._id, null, wcb), + (wcb) => remove_tablet_operator_to_list(app, tournament.key, match, wcb), + (wcb) => update_match_btp(app, match, wcb), + (wcb) => update_match_db(app, match, wcb), + (wcb) => update_court_db(app, match, wcb), + (wcb) => notify_change_match_edit(app, match, wcb), + (wcb) => notify_bupws(app, match, old_court, wcb), + (wcb) => remove_player_on_court(app, tournament.key, match._id, null, wcb), + (wcb) => update_btp_courts(app, tournament.key, match, wcb)], + (err) => { + return callback(err); + } + ); +} + + +async function call_match(app, tournament, match, old_court, callback) { + if (!match.setup.court_id || !match._id) { + return callback("Match cannot be called court_id or _id not given."); + } + if (match_completly_initialized(match.setup) == false) { + return callback("Match cannot be called one or more Teams are not set."); + } + console.log('[bts] auto_call_trace:call_match_start', { + ts: Date.now(), + tournament_key: tournament && tournament.key, + match_id: match._id, + court_id: match.setup && match.setup.court_id, + old_court, + state: match.setup && match.setup.state, + now_on_court: match.setup && match.setup.now_on_court, + }); + async.waterfall([ (wcb) => add_called_timestamp(match, wcb), + (wcb) => auto_assign_technical_officials_for_match(app, tournament, match._id, (assignErr) => { + if (assignErr) { + return wcb(assignErr); + } + app.db.matches.findOne({ _id: match._id, tournament_key: tournament.key }, (reloadErr, refreshed_match) => { + if (reloadErr) { + return wcb(reloadErr); + } + if (refreshed_match) { + // Preserve transient on-court fields that are only persisted later in this waterfall. + refreshed_match.setup = refreshed_match.setup || {}; + refreshed_match.setup.court_id = match.setup.court_id; + refreshed_match.setup.now_on_court = match.setup.now_on_court; + refreshed_match.setup.called_timestamp = match.setup.called_timestamp; + refreshed_match.setup.state = match.setup.state; + match = refreshed_match; + } + return wcb(null); + }); + }, { allow_on_match_call: true }), + (wcb) => drop_unsupported_court_officials(app, tournament, match, wcb), + (wcb) => add_tabletoperators(app, tournament, match, wcb), + (wcb) => set_umpires_on_court(app, tournament, match, wcb), + (wcb) => remove_highlight_preparation(match, wcb), + (wcb) => update_match_btp(app, match, wcb), + (wcb) => update_match_db(app, match, wcb), + (wcb) => update_court_db(app, match, wcb), + (wcb) => notify_change_match_edit(app, match, wcb), + (wcb) => notify_bupws(app, match, old_court, wcb), + (wcb) => notify_change_match_called_on_court(app, match, wcb), + (wcb) => set_player_on_court(app, tournament.key, match.setup, wcb), + (wcb) => set_player_on_tablet(app, tournament.key, match.setup, wcb), + (wcb) => update_btp_courts(app, tournament.key, match, wcb), + (wcb) => auto_execute_preparation_selection_for_setup(app, tournament, match.setup, wcb)], + (err) => { + console.log('[bts] auto_call_trace:call_match_end', { + ts: Date.now(), + tournament_key: tournament && tournament.key, + match_id: match && match._id, + court_id: match && match.setup && match.setup.court_id, + called_timestamp: match && match.setup && match.setup.called_timestamp, + error: err ? (err.message || String(err)) : null, + }); + return callback(err, match); + } + ); +} + +async function switch_court(app, tournament, match, old_court, callback) { + if (!match.setup.court_id || !match._id) { + return callback("Match cannot be switched to another court: court_id or _id not given."); + } + if (match_completly_initialized(match.setup) == false) { + return callback("Match cannot be switched to another court: one or more Teams are not set."); + } + async.waterfall([ + (wcb) => add_tabletoperators(app, tournament, match, wcb), + (wcb) => set_umpires_on_court(app, tournament, match, wcb), + (wcb) => remove_highlight_preparation(match, wcb), + (wcb) => update_match_btp(app, match, wcb), + (wcb) => update_match_db(app, match, wcb), + (wcb) => update_court_db(app, match, wcb), + (wcb) => notify_change_match_edit(app, match, wcb), + (wcb) => notify_bupws(app, match, old_court, wcb), + (wcb) => notify_change_match_called_on_court(app, match, wcb), + (wcb) => set_player_on_court(app, tournament.key, match.setup, wcb), + (wcb) => set_player_on_tablet(app, tournament.key, match.setup, wcb), + (wcb) => update_btp_courts(app, tournament.key, match, wcb) + ], + (err) => { + return callback(err, match); + } + ); +} + +function match_completly_initialized(setup) { + if (!setup || setup.teams[0].players.length == 0 || setup.teams[1].players.length == 0) { + return false; + } + return true; +} + +function add_called_timestamp(match, callback) { + const setup = match.setup; + const called_timestamp = Date.now(); + setup.called_timestamp = called_timestamp; + setup.state = 'oncourt'; + remove_preparation_call_timestamp(setup); + return callback(null); +} + +function remove_called_timestamp(match, callback) { + const setup = match.setup; + setup.called_timestamp = undefined; + setup.state = 'scheduled'; + return callback(null); +} + +function normalize_preparation_state(setup) { + if (!setup) { + return setup; + } + if (Number(setup.highlight) > 0) { + return setup; + } + if (setup.state === 'preparation') { + setup.state = 'scheduled'; + } + if (setup.preparation_call_timestamp != null) { + setup.preparation_call_timestamp = undefined; + } + return setup; +} + +function add_preparation_call_timestamp(db, tournament_key, setup, location_id) { + return new Promise((resolve) => { + const stournament = require('./stournament'); + + stournament.get_locations(db, tournament_key, (err, all_locations) => { + for (const location of all_locations) { + if (location._id == location_id) { + setup.highlight = location.highlight; + setup.location_id = location_id; + setup.preparation_call_timestamp = Date.now(); + setup.state = 'preparation'; + resolve(setup); + return; + } + } + serror.silent("Can't call a match in preparation for location ' + location_id."); + setup.highlight = 0; + resolve(setup); + }); + }); +} + +function resolve_location_id_for_setup(app, tournament_key, setup, callback) { + if (setup && setup.location_id) { + return callback(null, setup.location_id); + } + if (!(setup && setup.court_id)) { + return callback(null, null); + } + app.db.courts.findOne({ tournament_key, _id: setup.court_id }, (err, court) => { + if (err) return callback(err); + return callback(null, court ? court.location_id : null); + }); +} + +function auto_execute_preparation_selection(app, tournament, location_id, callback) { + if (!tournament || !tournament.key || !location_id) { + return callback(null); + } + if (!is_tournament_automation_enabled(tournament)) { + return callback(null); + } + if (!tournament.call_preparation_matches_automatically_enabled) { + return callback(null); + } + + const match_automation = require('./match_automation'); + match_automation.fetch_location_preparation_selection(app, tournament.key, location_id) + .then((selection) => { + async.eachSeries(selection.selected_matches || [], (match, cb) => { + call_match_in_preparation(app, tournament, match, location_id, cb); + }, callback); + }) + .catch((err) => callback(err)); +} + +function auto_execute_preparation_selections(app, tournament, callback) { + if (!tournament || !tournament.key) { + return callback(null); + } + if (!is_tournament_automation_enabled(tournament)) { + return callback(null); + } + if (!tournament.call_preparation_matches_automatically_enabled) { + return callback(null); + } + + app.db.locations.find({ tournament_key: tournament.key }).sort({ name: 1 }).exec((err, locations) => { + if (err) { + return callback(err); + } + + async.eachSeries(locations || [], (location, cb) => { + if (!location || !location._id) { + return cb(null); + } + return auto_execute_preparation_selection(app, tournament, location._id, cb); + }, callback); + }); +} + +function auto_execute_preparation_selection_for_setup(app, tournament, setup, callback) { + if (!tournament || !tournament.key) { + return callback(null); + } + return auto_execute_preparation_selections(app, tournament, (err) => { + if (err) { + return callback(err); + } + return auto_assign_technical_officials_for_preparation_matches(app, tournament, callback); + }); +} + +function queue_auto_execute_preparation_selections(app, tournament_key, callback) { + if (!app || !app.db || !tournament_key) { + if (callback) { + callback(null); + } + return; + } + + const pending = pending_preparation_selection_runs.get(tournament_key); + if (pending) { + if (callback) { + pending.callbacks.push(callback); + } + return; + } + + const state = { + callbacks: callback ? [callback] : [], + }; + pending_preparation_selection_runs.set(tournament_key, state); + + update_queue.instance().execute(update_queue.named('auto_execute_preparation_selections', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, (err, tournament) => { + if (err) { + return reject(err); + } + if (!tournament) { + return reject(new Error('Cannot find tournament ' + tournament_key)); + } + return auto_execute_preparation_selections(app, tournament, (execErr) => { + if (execErr) { + return reject(execErr); + } + return resolve(true); + }); + }); + }))).then(() => { + const current = pending_preparation_selection_runs.get(tournament_key); + pending_preparation_selection_runs.delete(tournament_key); + queue_auto_assign_technical_officials_when_available(app, tournament_key); + (current?.callbacks || []).forEach((cb) => cb(null)); + }).catch((err) => { + const current = pending_preparation_selection_runs.get(tournament_key); + pending_preparation_selection_runs.delete(tournament_key); + (current?.callbacks || []).forEach((cb) => cb(err)); + console.warn('[bts] failed to queue auto_execute_preparation_selections', err && (err.stack || err.message || String(err))); + }); +} + +function technical_official_auto_assignment_mode_supports_preparation(mode) { + return mode === 'on_preparation_call' || mode === 'when_available'; +} + +function technical_official_auto_assignment_mode_supports_match_call(mode) { + return mode === 'on_match_call_if_possible' || mode === 'on_preparation_call' || mode === 'when_available'; +} + +function build_official_wait_reentry_set_obj(role, ts) { + const setObj = { + inactive_list: null, + service_judge_pause: null, + umpire_pause: null, + service_judge_manual_pause: null, + umpire_manual_pause: null, + service_judge_wait: null, + umpire_wait: null, + service_judge_on_court: null, + umpire_on_court: null, + is_planed_as_service_judge: false, + is_planed_as_umpire: false, + }; + if (role === 'umpire') { + setObj.umpire_wait = ts; + } else if (role === 'service_judge') { + setObj.service_judge_wait = ts; + } + return setObj; +} + +function drop_unsupported_court_officials(app, tournament, match, callback) { + if (!match?.setup?.court_id) { + return callback(null); + } + + app.db.courts.findOne({ tournament_key: tournament.key, _id: match.setup.court_id }, (courtErr, court) => { + if (courtErr) { + return callback(courtErr); + } + if (!court) { + return callback(null); + } + + const admin = require('./admin'); + const setup = match.setup || {}; + const releases = []; + if (court.has_umpire === false && setup.umpire) { + const official = setup.umpire; + admin._remove_official_from_setup(setup, 'umpire'); + if (official && official._id) { + releases.push({ + official_id: official._id, + role: 'umpire', + wait_ts: Number.isFinite(Number(official.umpire_wait)) ? Number(official.umpire_wait) : (Date.now() / 10), + }); + } + } + if (court.has_service_judge === false && setup.service_judge) { + const official = setup.service_judge; + admin._remove_official_from_setup(setup, 'service_judge'); + if (official && official._id) { + releases.push({ + official_id: official._id, + role: 'service_judge', + wait_ts: Number.isFinite(Number(official.service_judge_wait)) ? Number(official.service_judge_wait) : (Date.now() / 10), + }); + } + } + + if (releases.length === 0) { + return callback(null); + } + + match.btp_needsync = true; + async.eachSeries(releases, (release, cb) => { + app.db.umpires.update( + { _id: release.official_id, tournament_key: tournament.key }, + { $set: build_official_wait_reentry_set_obj(release.role, release.wait_ts) }, + {}, + cb + ); + }, (updateErr) => { + if (updateErr) { + return callback(updateErr); + } + app.db.umpires.find( + { tournament_key: tournament.key, _id: { $in: releases.map((release) => release.official_id) } }, + (findErr, updatedOfficials) => { + if (findErr) { + return callback(findErr); + } + app.db.umpires.find({ tournament_key: tournament.key }, (allErr, all_umpires) => { + if (allErr) { + return callback(allErr); + } + (updatedOfficials || []).forEach((official) => { + admin.notify_change(app, tournament.key, 'umpire_updated', official); + }); + admin.notify_change(app, tournament.key, 'umpires_changed', { all_umpires }); + return callback(null); + }); + } + ); + }); + }); +} + +function is_nonblocking_auto_assign_error(err) { + return !!(err && ( + /Match already has assigned umpire/.test(err.message) || + /No umpire available/.test(err.message) || + /Match has no assigned umpire/.test(err.message) || + /Match already has assigned service judge/.test(err.message) || + /No service judge available/.test(err.message) + )); +} + +function auto_assign_technical_officials_for_match(app, tournament, match_id, callback, options = {}) { + if (!is_tournament_automation_enabled(tournament)) { + return callback(null, false); + } + const mode = tournament.technical_official_auto_assignment_mode || 'manual_only'; + if (!technical_official_auto_assignment_mode_supports_match_call(mode)) { + return callback(null, false); + } + if ((tournament.official_rotation_mode || 'umpire_and_service_judge') === 'disabled') { + return callback(null, false); + } + if (mode === 'on_match_call_if_possible' && options.allow_on_match_call !== true) { + return callback(null, false); + } + + const admin = require('./admin'); + let changed = false; + const load_target_court = () => new Promise((resolve, reject) => { + if (!app?.db?.matches || typeof app.db.matches.findOne !== 'function') { + return resolve(null); + } + app.db.matches.findOne({ _id: match_id, tournament_key: tournament.key }, (matchErr, match) => { + if (matchErr) { + return reject(matchErr); + } + const court_id = match?.setup?.court_id; + if (!court_id) { + return resolve(null); + } + if (!app?.db?.courts || typeof app.db.courts.findOne !== 'function') { + return resolve(null); + } + app.db.courts.findOne({ tournament_key: tournament.key, _id: court_id }, (courtErr, court) => { + if (courtErr) { + return reject(courtErr); + } + return resolve(court || null); + }); + }); + }); + const try_auto_assign = (assign_fn) => { + return assign_fn(app, tournament.key, match_id, { skip_btp_push: true }) + .then(() => { + changed = true; + }) + .catch((err) => { + if (is_nonblocking_auto_assign_error(err)) { + return null; + } + throw err; + }); + }; + + Promise.resolve() + .then(() => load_target_court()) + .then((court) => { + if (court && court.has_umpire === false) { + return null; + } + return try_auto_assign(admin._assign_next_umpire_to_match).then(() => court); + }) + .then((court) => { + if ((tournament.official_rotation_mode || 'umpire_and_service_judge') !== 'umpire_and_service_judge') { + return null; + } + if (court && court.has_service_judge === false) { + return null; + } + return try_auto_assign(admin._assign_next_service_judge_to_match); + }) + .then(() => callback(null, changed)) + .catch((err) => callback(err)); +} + +function fetch_technical_official_assignment_targets(app, tournament, callback) { + if (!tournament || !tournament.key) { + return callback(null, []); + } + + app.db.matches + .find({ tournament_key: tournament.key, 'setup.state': 'preparation' }) + .sort({ 'setup.preparation_call_timestamp': 1 }) + .exec((err, preparation_matches) => { + if (err) { + return callback(err); + } + + const targets = []; + const seen_match_ids = new Set(); + (preparation_matches || []).forEach((match) => { + if (!match || !match._id || seen_match_ids.has(match._id)) { + return; + } + if (!match_needs_technical_official_assignment(match, tournament)) { + return; + } + seen_match_ids.add(match._id); + targets.push(match); + }); + + if ((tournament.technical_official_auto_assignment_mode || 'manual_only') !== 'when_available') { + return callback(null, targets); + } + + const match_automation = require('./match_automation'); + match_automation.fetch_all_location_preparation_selections(app, tournament.key, { + ignore_technical_officials_available_rule: true, + }) + .then((selections) => { + (selections || []).forEach((selection) => { + (selection.selected_matches || []).forEach((match) => { + if (!match || !match._id || seen_match_ids.has(match._id)) { + return; + } + if (!match_needs_technical_official_assignment(match, tournament)) { + return; + } + seen_match_ids.add(match._id); + targets.push(match); + }); + }); + + app.db.umpires.find({ tournament_key: tournament.key, umpire_wait: { $ne: null } }, (umpireErr, waiting_umpires) => { + if (umpireErr) { + return callback(umpireErr); + } + const available_umpire_count = Array.isArray(waiting_umpires) ? waiting_umpires.length : 0; + if (available_umpire_count <= targets.length) { + return callback(null, targets); + } + + match_automation.fetch_global_preparation_candidates(app, tournament.key, { + ignore_technical_officials_available_rule: true, + }) + .then((global_candidates) => { + (global_candidates || []).forEach((match) => { + if (!match || !match._id || seen_match_ids.has(match._id)) { + return; + } + if (targets.length >= available_umpire_count) { + return; + } + if (!match_needs_technical_official_assignment(match, tournament)) { + return; + } + seen_match_ids.add(match._id); + targets.push(match); + }); + callback(null, targets); + }) + .catch((globalErr) => callback(globalErr)); + }); + }) + .catch((selectionErr) => callback(selectionErr)); + }); +} + +function auto_assign_technical_officials_for_preparation_matches(app, tournament, callback) { + if (!tournament || !tournament.key) { + return callback(null); + } + if (!is_tournament_automation_enabled(tournament)) { + return callback(null); + } + if ((tournament.technical_official_auto_assignment_mode || 'manual_only') !== 'when_available') { + return callback(null); + } + if ((tournament.official_rotation_mode || 'umpire_and_service_judge') === 'disabled') { + return callback(null); + } + + fetch_technical_official_assignment_targets(app, tournament, (targetErr, matches) => { + if (targetErr) { + return callback(targetErr); + } + + const btp_manager = require('./btp_manager'); + async.eachSeries(matches || [], (match, cb) => { + if (!match || !match._id) { + return cb(null); + } + return auto_assign_technical_officials_for_match(app, tournament, match._id, (assignErr, changed) => { + if (assignErr) { + return cb(assignErr); + } + if (!changed) { + return cb(null); + } + fetch_match(app, tournament.key, match._id) + .then((updatedMatch) => { + btp_manager.update_highlight(app, updatedMatch); + cb(null); + }) + .catch(cb); + }); + }, callback); + }); +} + +function queue_auto_assign_technical_officials_when_available(app, tournament_key, callback) { + if (!app || !app.db || !tournament_key) { + if (callback) { + callback(null); + } + return; + } + + const pending = pending_technical_official_assignment_runs.get(tournament_key); + if (pending) { + if (callback) { + pending.callbacks.push(callback); + } + return; + } + + const state = { + callbacks: callback ? [callback] : [], + }; + pending_technical_official_assignment_runs.set(tournament_key, state); + + update_queue.instance().execute(update_queue.named('auto_assign_technical_officials_when_available', () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, (err, tournament) => { + if (err) { + return reject(err); + } + if (!tournament) { + return reject(new Error('Cannot find tournament ' + tournament_key)); + } + return auto_assign_technical_officials_for_preparation_matches(app, tournament, (execErr) => { + if (execErr) { + return reject(execErr); + } + return resolve(true); + }); + }); + }))).then(() => { + const current = pending_technical_official_assignment_runs.get(tournament_key); + pending_technical_official_assignment_runs.delete(tournament_key); + (current?.callbacks || []).forEach((cb) => cb(null)); + }).catch((err) => { + const current = pending_technical_official_assignment_runs.get(tournament_key); + pending_technical_official_assignment_runs.delete(tournament_key); + (current?.callbacks || []).forEach((cb) => cb(err)); + console.warn('[bts] failed to queue auto_assign_technical_officials_when_available', err && (err.stack || err.message || String(err))); + }); +} + +function remove_preparation_call_timestamp(setup) { + setup.preparation_call_timestamp = undefined; +} +function remove_tablet_operator_to_list(app, tkey, match, callback) { + add_tabletoperator_to_tabletoperator_list_by_match(app, tkey, match); + + const setup = match.setup; + setup.tabletoperators = undefined; + + return callback(null); +} + +async function add_tabletoperators(app, tournament, match, callback) { + const admin = require('./admin'); // avoid dependency cycle + const btp_manager = require('./btp_manager'); + + const court_id = match.setup.court_id; + const match_id = match._id; + + if (!court_id || !match_id) { + return callback(null); + } + + const setup = match.setup; + + try { + if (is_tournament_automation_enabled(tournament) && (tournament.tabletoperator_enabled && tournament.tabletoperator_enabled == true)) { + if (!setup.tabletoperators || setup.tabletoperators == null) { + + const fetch_result = await fetch_tabletoperator(admin, app, tournament.key, court_id); + let value = []; + if (tournament.tabletoperator_with_state_from_match_enabled && typeof(fetch_result) == "undefined") { + value.push({ + asian_name: false, + name: setup.teams[0].players[0].state, + firstname: "", + lastname: "", + btp_id: -1}); + } else { + value = fetch_result; + } + + if (!setup.umpire || !setup.umpire.name || (tournament.tabletoperator_with_umpire_enabled && tournament.tabletoperator_with_umpire_enabled == true)) { + setup.tabletoperators = value; + } + } + } + } catch (err) { + return callback(err); + } + + if (setup.tabletoperators) { + for (let operator of setup.tabletoperators) { + operator.checked_in = false; + } + btp_manager.update_players(app, tournament.key, setup.tabletoperators); + } + return callback(null); +} + +async function set_umpires_on_court(app, tournament, match, callback) { + const setup = match.setup; + const court_id = setup.court_id; + if (!court_id) { + return callback(null); + } + + if (setup.umpire) { + const umpire = setup.umpire; + umpire.umpire_on_court = court_id; + umpire.is_planed_as_umpire = false; + umpire.is_planed_as_service_judge = false; + umpire.service_judge_on_court = null; + umpire.umpire_wait = null; + umpire.service_judge_wait = null; + umpire.umpire_pause = null; + umpire.service_judge_pause = null; + umpire.inactive_list = null; + umpire.last_time_on_court_ts = setup.called_timestamp; + umpire.status = 'oncourt'; + umpire.court_id = court_id; + sync_technical_official_checked_in(umpire, tournament); + + update_umpire(app, tournament.key, umpire); + } + + if (setup.service_judge) { + const service_judge = setup.service_judge; + service_judge.service_judge_on_court = court_id; + service_judge.is_planed_as_umpire = false; + service_judge.is_planed_as_service_judge = false; + service_judge.umpire_on_court = null; + service_judge.umpire_wait = null; + service_judge.service_judge_wait = null; + service_judge.umpire_pause = null; + service_judge.service_judge_pause = null; + service_judge.inactive_list = null; + service_judge.last_time_on_court_ts = setup.called_timestamp; + service_judge.status = 'oncourt'; + service_judge.court_id = court_id; + sync_technical_official_checked_in(service_judge, tournament); + + update_umpire(app, tournament.key, service_judge); + } + return callback(null); +} + +function remove_highlight_preparation(match, callback){ + const setup = match.setup; + setup.highlight = 0; + normalize_preparation_state(setup); + + return callback(null); +} + +function update_match_db (app, match, callback) { + const setup = match.setup; + const match_q = {_id: match._id}; + + app.db.matches.update(match_q, {$set: {setup}}, {}, (err) => { + if (err) { + return callback(err); + } + + return callback(null); + }); +} + +function update_match_btp(app, match, callback) { + const btp_manager = require('./btp_manager'); + + // this function also send the changes of this match to btp + btp_manager.update_highlight(app, match); + + return callback(null); +} + +function update_court_db (app, match, callback) { + const court_q = {_id: match.setup.court_id}; + app.db.courts.find(court_q, (err, courts) => { + if (err) { + return callback(err); + } + + if (courts.length !== 1) { + return callback(null); + } + + app.db.courts.update(court_q, {$set: {match_id: match._id}}, {}, (err) => { + return callback(err); + }); + }); +} + +function notify_change_match_edit (app, match, callback) { + const admin = require('./admin'); // avoid dependency cycle + + admin.notify_change(app, match.tournament_key, 'match_edit', { match__id: match._id, + match: match}); + + return callback(null); +} + + +function notify_change_match_called_on_court (app, match, callback) { + const admin = require('./admin'); // avoid dependency cycle + + admin.notify_change(app, match.tournament_key, 'match_called_on_court', match); + + return callback(null); +} + +function notify_bupws(app, match, old_court, callback) { + const bupws = require('./bupws'); + console.log('[bts] auto_call_trace:notify_bupws', { + ts: Date.now(), + tournament_key: match && match.tournament_key, + match_id: match && match._id, + court_id: match && match.setup && match.setup.court_id, + old_court, + state: match && match.setup && match.setup.state, + now_on_court: match && match.setup && match.setup.now_on_court, + called_timestamp: match && match.setup && match.setup.called_timestamp, + }); + + bupws.handle_score_change(app, match.tournament_key, match.setup.court_id); + + if(old_court) { + bupws.handle_score_change(app, match.tournament_key, old_court); + } + + return callback(null); +} + +function serialized(fn) { + let queue = Promise.resolve(); + return (...args) => { + const res = queue.then(() => fn(...args)); + queue = res.catch(() => { }); + return res; + } +} + +const fetch_tabletoperator = serialized(get_last_looser_on_court); + +function get_last_looser_on_court(admin, app, tkey, court_id) { + return new Promise((resolve, reject) => { + const tabletoperator_querry = { 'tournament_key': tkey, court: null }; + let tabletoperators = undefined; + app.db.tabletoperators.find(tabletoperator_querry).sort({ 'start_ts': 1 }).limit(1).exec((err, tabletoperator) => { + if (err) { + return reject(err); + } + var returnvalue = undefined; + if (tabletoperator && tabletoperator.length == 1) { + returnvalue = tabletoperator[0].tabletoperator + app.db.tabletoperators.update({ _id: tabletoperator[0]._id, tournament_key: tkey }, { $set: { court: court_id } }, { returnUpdatedDocs: true }, function (err, numAffected, changed_tabletoperator) { + if (err) { + return reject(err); + } + admin.notify_change(app, tkey, 'tabletoperator_removed', { tabletoperator: changed_tabletoperator }); + return resolve(returnvalue); + }); + } else { + return resolve(returnvalue); + } + }); + }); +} + +function calc_match_set_player_on_tablet(match, match_on_court_setup) { + return new Promise((resolve) => { + if(match.setup.now_on_court == false) { + resolve(null); + } + + if(!match_on_court_setup.tabletoperators || match_on_court_setup.tabletoperators.length <1) { + resolve(null); + } + + let tablet_operatorns_btp_ids = [match_on_court_setup.tabletoperators[0].btp_id]; + + if(match_on_court_setup.tabletoperators.length > 1) { + tablet_operatorns_btp_ids.push(match_on_court_setup.tabletoperators[1].btp_id); + } + + let change = false; + + if (match.setup.teams[0].players.length > 0 && tablet_operatorns_btp_ids.includes(match.setup.teams[0].players[0].btp_id)) { + match.setup.teams[0].players[0].now_tablet_on_court = match_on_court_setup.court_id; + match.setup.teams[0].players[0].checked_in = false; + change = true; + } + + if (match.setup.teams[0].players.length > 1 && tablet_operatorns_btp_ids.includes(match.setup.teams[0].players[1].btp_id)) { + match.setup.teams[0].players[1].now_tablet_on_court = match_on_court_setup.court_id; + match.setup.teams[0].players[1].checked_in = false; + change = true; + } + + if (match.setup.teams[1].players.length > 0 && tablet_operatorns_btp_ids.includes(match.setup.teams[1].players[0].btp_id)) { + match.setup.teams[1].players[0].now_tablet_on_court = match_on_court_setup.court_id; + match.setup.teams[1].players[0].checked_in = false; + change = true; + } + + if (match.setup.teams[1].players.length > 1 && tablet_operatorns_btp_ids.includes(match.setup.teams[1].players[1].btp_id)) { + match.setup.teams[1].players[1].now_tablet_on_court = match_on_court_setup.court_id; + match.setup.teams[1].players[1].checked_in = false; + change = true; + } + + if (change) { + resolve(match); + } + + resolve(null); + }); +} + +async function set_player_on_tablet (app, tkey, match_on_court_setup, callback) { + + if(!match_on_court_setup.tabletoperators || match_on_court_setup.tabletoperators.length == 0) { + return callback(null); + } + + const admin = require('./admin'); // avoid dependency cycle + app.db.matches.find({'tournament_key': tkey}, async (err, matches) => { + if (err) { + callback(err); + } + + async.each(matches, async (match, cb) => { + const changed_match = await calc_match_set_player_on_tablet(match, match_on_court_setup) + if (changed_match != null) { + const setup = changed_match.setup; + const match_q = {_id: changed_match._id}; + app.db.matches.update(match_q, {$set: {setup}}, {}, (err) => { + if (err) return callback(err); + admin.notify_change(app, changed_match.tournament_key, 'update_player_status', {match__id: changed_match._id, + btp_winner: changed_match.btp_winner, + setup: changed_match.setup}); + }); + } + }); + + callback(null); + }); +} + +function calc_match_set_player_on_court(match, match_on_court_setup) { + return new Promise((resolve) => { + if(match.setup.now_on_court == false) { + resolve(null); + } + + let on_court_btp_ids = [match_on_court_setup.teams[0].players[0].btp_id, + match_on_court_setup.teams[1].players[0].btp_id]; + + if(match_on_court_setup.teams[0].players.length > 1) { + on_court_btp_ids.push(match_on_court_setup.teams[0].players[1].btp_id); + } + + if(match_on_court_setup.teams[1].players.length > 1) { + on_court_btp_ids.push(match_on_court_setup.teams[1].players[1].btp_id); + } + + let change = false; + + if (match.setup.teams[0].players.length > 0 && on_court_btp_ids.includes(match.setup.teams[0].players[0].btp_id)) { + match.setup.teams[0].players[0].now_playing_on_court = match_on_court_setup.court_id; + match.setup.teams[0].players[0].tablet_break_active = false; + match.setup.state = 'blocked'; + change = true; + } + + if (match.setup.teams[0].players.length > 1 && on_court_btp_ids.includes(match.setup.teams[0].players[1].btp_id)) { + match.setup.teams[0].players[1].now_playing_on_court = match_on_court_setup.court_id; + match.setup.teams[0].players[1].tablet_break_active = false; + match.setup.state = 'blocked'; + change = true; + } + + if (match.setup.teams[1].players.length > 0 && on_court_btp_ids.includes(match.setup.teams[1].players[0].btp_id)) { + match.setup.teams[1].players[0].now_playing_on_court = match_on_court_setup.court_id; + match.setup.teams[1].players[0].tablet_break_active = false; + match.setup.state = 'blocked'; + change = true; + } + + if (match.setup.teams[1].players.length > 1 && on_court_btp_ids.includes(match.setup.teams[1].players[1].btp_id)) { + match.setup.teams[1].players[1].now_playing_on_court = match_on_court_setup.court_id; + match.setup.teams[1].players[1].tablet_break_active = false; + match.setup.state = 'blocked'; + change = true; + } + if (change) { + resolve(match); + } + resolve(null); + }); +} + +async function set_player_on_court (app, tkey, match_on_court_setup, callback) { + const admin = require('./admin'); // avoid dependency cycle + app.db.matches.find({'tournament_key': tkey}, async (err, matches) => { + if (err) { + callback(err); + } + + async.each(matches, async (match) => { + const changed_match = await calc_match_set_player_on_court(match, match_on_court_setup); + if (changed_match != null) { + const setup = changed_match.setup; + const match_q = {_id: changed_match._id}; + app.db.matches.update(match_q, {$set: {setup}}, {}, (err) => { + if (err) return callback(err); + + admin.notify_change(app, changed_match.tournament_key, 'update_player_status', {match__id: changed_match._id, + btp_winner: changed_match.btp_winner, + setup: changed_match.setup}); + }); + } + }); + + callback(null); + }); +} + +function add_player_to_tabletoperator_list(app, tournament_key, cur_match_id, end_ts, callback) { + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return callback(err); + } + if ((tournament.tabletoperator_enabled && tournament.tabletoperator_enabled == true)) { + app.db.matches.findOne({ 'tournament_key': tournament_key, '_id': cur_match_id }, (err, cur_match) => { + if (err) { + return callback(err); + } + add_player_to_tabletoperator_list_by_match(app, tournament, tournament_key, cur_match, end_ts, callback) + }); + } else { + return callback(null); + } + }); +} + +function add_player_to_tabletoperator_list_by_match(app, tournament, tournament_key, cur_match, end_ts, callback) { + if (cur_match.network_score) { + // walkovers and retirements will not be recorgnized. + app.db.tabletoperators.findOne({ 'tournament_key': tournament_key, 'match_id': cur_match._id }, (err, no_tabletoperator) => { + if (err) { + return callback(err); + } + if (no_tabletoperator == null) { + const round = cur_match.setup.match_name; + var team = null; + + if (tournament.tabletoperator_winner_of_quaterfinals_enabled && (round == 'VF' || round == 'QF')) { + team = cur_match.setup.teams[cur_match.btp_winner - 1]; + } else { + const index = cur_match.btp_winner % 2; + team = cur_match.setup.teams[index]; + } + + if (tournament.tabletoperator_with_state_from_match_enabled) { + return callback(null); + } + + if (team && typeof team.players !== 'undefined') { + var teams = []; + if (tournament.tabletoperator_split_doubles && team.players.length > 1) { + for (const player of team.players) { + var toinsert = player + if (tournament.tabletoperator_with_state_enabled && player.state) { + toinsert = create_team_from_player_state(player); + } + var newTeam = { + players: [toinsert] + }; + teams.push(newTeam); + } + } else { + var toinsert = team; + if (tournament.tabletoperator_with_state_enabled && team.players[0].state) { + toinsert = { + players: [create_team_from_player_state(team.players[0])] + }; + } + teams.push(toinsert); + } + + var i = 0; + for (const t of teams) { + var tabletoperator = []; + t.players.forEach((player) => { + tabletoperator.push(player); + }); + + const new_tabletoperator = { + tournament_key, + tabletoperator, + 'match_id': cur_match._id, + 'start_ts': end_ts, + 'end_ts': null, + 'court': null, + 'played_on_court': (cur_match.setup.court_id ? cur_match.setup.court_id : null) + }; + + app.db.tabletoperators.insert(new_tabletoperator, function (err, inserted_t) { + if (err) { + return callback(err); + } + const admin = require('./admin'); // avoid dependency cycle + admin.notify_change(app, tournament_key, 'tabletoperator_add', { tabletoperator: inserted_t }); + if (i == teams.length - 1) { + callback(null); + } + i++; + }); + } + } else { + return callback(null); + } + } else { + return callback(null); + } + }); + } else { + return callback(null); + } +} +function fetch_match(app, tournament_key, match_id) { + return new Promise((resolve, reject) => { + app.db.matches.findOne({ tournament_key: tournament_key, _id: match_id }, async (err, match) => { + if (err) { + return reject(err); + } + if (match != null) { + return resolve(match) + } else { + return reject("Match cannot be fetched from DB 111 " + match_id); + } + }); + }); +} + +function create_team_from_player_state(player) { + return { + "asian_name": false, + "name": player.state, + "firstname": "", + "lastname": "", + "btp_id": -1 + }; +} + +function add_tabletoperator_to_tabletoperator_list_by_match(app, tournament_key, cur_match) { + + if(cur_match.setup.tabletoperators) { + var tabletoperator = cur_match.setup.tabletoperators; + + const new_tabletoperator = { + tournament_key, + tabletoperator, + 'match_id': cur_match._id, + 'start_ts': tabletoperator[0].last_time_on_court_ts, + 'end_ts': null, + 'court': null, + 'played_on_court': (cur_match.setup.court_id ? cur_match.setup.court_id : null) + }; + + app.db.tabletoperators.insert(new_tabletoperator, function (err, inserted_t) { + if (err) { + ws.respond(msg, err); + return; + } + const admin = require('./admin'); // avoid dependency cycle + admin.notify_change(app, tournament_key, 'tabletoperator_add', { tabletoperator: inserted_t }); + }); + } + +} + + +function remove_player_on_court (app, tkey, cur_match_id, end_ts = null, callback) { + const admin = require('./admin'); // avoid dependency cycle + const btp_manager = require('./btp_manager'); + + app.db.matches.findOne({'tournament_key': tkey, '_id': cur_match_id}, (err, cur_match) => { + if (err) return callback(err); + + app.db.matches.find({'tournament_key': tkey}, async (err, matches) => { + if (err) { + return callback(err); + } + + async.each(matches, (match, cb) => { + if(!match.setup) + { + return cb(null); + } + + if(match.setup.now_on_court == true) { + return cb(null); + } + + const match_id = match._id; + const players_to_change = []; + const is_finished_match = match_id === cur_match_id; + let remove_btp_ids = [ cur_match.setup.teams[0].players[0].btp_id, + cur_match.setup.teams[1].players[0].btp_id]; + + if(cur_match.setup.teams[0].players.length > 1) { + remove_btp_ids.push(cur_match.setup.teams[0].players[1].btp_id); + } + + if(cur_match.setup.teams[1].players.length > 1) { + remove_btp_ids.push(cur_match.setup.teams[1].players[1].btp_id); + } + + let change = false; + + if (match.setup.teams[0].players.length > 0 && + remove_btp_ids.includes(match.setup.teams[0].players[0].btp_id) && + (match.setup.teams[0].players[0].now_playing_on_court || is_finished_match)) { + match.setup.teams[0].players[0].now_playing_on_court = false; + match.setup.teams[0].players[0].checked_in = false; + if(end_ts) { + match.setup.teams[0].players[0].last_time_on_court_ts = end_ts; + } + players_to_change.push(match.setup.teams[0].players[0]); + change = true; + } + + if (match.setup.teams[0].players.length > 1 && + remove_btp_ids.includes(match.setup.teams[0].players[1].btp_id) && + (match.setup.teams[0].players[1].now_playing_on_court || is_finished_match)) { + match.setup.teams[0].players[1].now_playing_on_court = false; + match.setup.teams[0].players[1].checked_in = false; + if(end_ts) { + match.setup.teams[0].players[1].last_time_on_court_ts = end_ts; + } + players_to_change.push(match.setup.teams[0].players[1]); + change = true; + } + + if (match.setup.teams[1].players.length > 0 && + remove_btp_ids.includes(match.setup.teams[1].players[0].btp_id) && + (match.setup.teams[1].players[0].now_playing_on_court || is_finished_match)) { + match.setup.teams[1].players[0].now_playing_on_court = false; + match.setup.teams[1].players[0].checked_in = false; + if(end_ts) { + match.setup.teams[1].players[0].last_time_on_court_ts = end_ts; + } + players_to_change.push(match.setup.teams[1].players[0]); + change = true; + } + + if (match.setup.teams[1].players.length > 1 && + remove_btp_ids.includes(match.setup.teams[1].players[1].btp_id) && + (match.setup.teams[1].players[1].now_playing_on_court || is_finished_match)) { + match.setup.teams[1].players[1].now_playing_on_court = false; + match.setup.teams[1].players[1].checked_in = false; + if(end_ts) { + match.setup.teams[1].players[1].last_time_on_court_ts = end_ts; + } + players_to_change.push(match.setup.teams[1].players[1]); + change = true; + } + + if (change) { + btp_manager.update_players(app, tkey, players_to_change); + const setup = match.setup; + const match_q = {_id: match_id}; + app.db.matches.update(match_q, {$set: {setup}}, {}, (err) => { + if (err) return cb(err); + + admin.notify_change(app, match.tournament_key, 'update_player_status',{ match__id: match._id, + btp_winner: match.btp_winner, + setup: match.setup}); + + return cb(null); + }); + } else { + return cb(null); + } + }, callback); + }); + }); + +} + + +function remove_tablet_on_court (app, tkey, cur_match_id, end_ts, callback) { + const admin = require('./admin'); // avoid dependency cycle + app.db.tournaments.findOne({ key: tkey }, async (err, tournament) => { + if (err) { + return callback(err); + } + app.db.matches.findOne({'tournament_key': tkey, '_id': cur_match_id}, (err, cur_match) => { + if (err) return callback(err); + + app.db.matches.find({'tournament_key': tkey}, async (err, matches) => { + if (err) { + console.error(err); + return callback(err); + } + + async.each(matches, (match, cb) => { + + if(match.setup.now_on_court == true) { + return cb(null); + } + + if(!cur_match.setup.tabletoperators || cur_match.setup.tabletoperators == 0) { + return cb(null); + } + + const match_id = match._id; + let remove_btp_ids = [ cur_match.setup.tabletoperators[0].btp_id]; + + if(cur_match.setup.tabletoperators.length > 1) { + remove_btp_ids.push(cur_match.setup.tabletoperators[1].btp_id); + } + + let change = false; + + if (match.setup.teams[0].players.length > 0 && + remove_btp_ids.includes(match.setup.teams[0].players[0].btp_id)) { + reset_tabletoperator_settings_at_player(app, tkey, tournament, match.setup.teams[0].players[0], end_ts); + change = true; + } + + if (match.setup.teams[0].players.length > 1 && + remove_btp_ids.includes(match.setup.teams[0].players[1].btp_id)) { + reset_tabletoperator_settings_at_player(app, tkey, tournament, match.setup.teams[0].players[1], end_ts); + change = true; + } + + if (match.setup.teams[1].players.length > 0 && + remove_btp_ids.includes(match.setup.teams[1].players[0].btp_id)) { + reset_tabletoperator_settings_at_player(app, tkey, tournament, match.setup.teams[1].players[0], end_ts); + change = true; + } + + if (match.setup.teams[1].players.length > 1 && + remove_btp_ids.includes(match.setup.teams[1].players[1].btp_id)) { + reset_tabletoperator_settings_at_player(app, tkey, tournament, match.setup.teams[1].players[1], end_ts); + change = true; + } + + if (change) { + const setup = match.setup; + const match_q = {_id: match_id}; + + app.db.matches.update(match_q, {$set: {setup}}, {}, (err) => { + if (err) return cb(err); + admin.notify_change(app, match.tournament_key, 'update_player_status', { match__id: match._id, + btp_winner: match.btp_winner, + setup: match.setup}); + + return cb(null); + }); + } else { + return cb(null); + } + }, callback); + }); + }); + }); +} + +function reset_tabletoperator_settings_at_player(app, tkey, tournament, player, end_ts) { + const btp_manager = require('./btp_manager'); + + player.now_tablet_on_court = false; + const now = Date.now(); + if (tournament.tabletoperator_set_break_after_tabletservice && + (now + (parseInt(tournament.tabletoperator_break_seconds) * 1000)) >= player.last_time_on_court_ts + tournament.btp_settings.pause_duration_ms) { + var offset = 0; + if (tournament.tabletoperator_break_seconds) { + offset = (parseInt(tournament.tabletoperator_break_seconds) * 1000) - tournament.btp_settings.pause_duration_ms; + } + player.last_time_on_court_ts = end_ts + offset; + player.checked_in = false; + player.tablet_break_active = true; + btp_manager.update_players(app, tkey, [player]); + + } else { + if (player.last_time_on_court_ts) { + if ((now - player.last_time_on_court_ts) > tournament.btp_settings.pause_duration_ms) { + player.checked_in = true; + } + } + player.tablet_break_active = false; + btp_manager.update_players(app, tkey, [player]); + } +} + +function apply_official_on_court_release(official, role, end_ts, options = {}) { + if (!official) { + return official; + } + const official_rotation_mode = options.official_rotation_mode || 'umpire_and_service_judge'; + const technical_break_ms = Number(options.technical_official_break_after_assignment_ms) || 0; + const use_break = technical_break_ms > 0; + const set_pause_or_wait = (pause_field, wait_field, ts, pause_ts) => { + official.status = use_break ? 'pause' : 'ready'; + official[pause_field] = use_break ? pause_ts : null; + official[wait_field] = use_break ? null : ts; + }; + + official.umpire_on_court = null; + official.service_judge_on_court = null; + official.is_planed_as_umpire = false; + official.is_planed_as_service_judge = false; + official.last_time_on_court_ts = end_ts; + official.checked_in = false; + official.status = 'ready'; + official.court_id = null; + official.umpire_wait = null; + official.service_judge_wait = null; + official.umpire_pause = null; + official.service_judge_pause = null; + official.umpire_manual_pause = null; + official.service_judge_manual_pause = null; + official.inactive_list = null; + + if (official_rotation_mode === 'umpire_only') { + if (official.is_umpire === true || official.is_service_judge === true) { + set_pause_or_wait('umpire_pause', 'umpire_wait', end_ts, end_ts + technical_break_ms); + } else { + // Be tolerant for legacy/inconsistent role flags and keep the official in rotation. + set_pause_or_wait('umpire_pause', 'umpire_wait', end_ts, end_ts + technical_break_ms); + } + return official; + } + + if (role === 'umpire') { + if (official.is_service_judge === true) { + set_pause_or_wait('service_judge_pause', 'service_judge_wait', end_ts, end_ts + technical_break_ms); + } else if (official.is_umpire === true) { + set_pause_or_wait('umpire_pause', 'umpire_wait', end_ts + 100, end_ts + technical_break_ms + 100); + } else { + // Fall back to the role actually performed if role flags are missing. + set_pause_or_wait('umpire_pause', 'umpire_wait', end_ts + 100, end_ts + technical_break_ms + 100); + } + } else if (role === 'service_judge') { + if (official.is_umpire === true) { + set_pause_or_wait('umpire_pause', 'umpire_wait', end_ts, end_ts + technical_break_ms); + } else if (official.is_service_judge === true) { + set_pause_or_wait('service_judge_pause', 'service_judge_wait', end_ts + 100, end_ts + technical_break_ms + 100); + } else { + // Fall back to the role actually performed if role flags are missing. + set_pause_or_wait('service_judge_pause', 'service_judge_wait', end_ts + 100, end_ts + technical_break_ms + 100); + } + } + + sync_technical_official_checked_in(official, options.tournament || options.btp_settings); + return official; +} + +function apply_official_pause_expiry(official, options = {}) { + if (!official) { + return official; + } + const has_umpire_pause = official.umpire_pause != null; + const has_service_judge_pause = official.service_judge_pause != null; + const umpire_pause = has_umpire_pause ? Number(official.umpire_pause) : null; + const service_judge_pause = has_service_judge_pause ? Number(official.service_judge_pause) : null; + + if (!has_umpire_pause && !has_service_judge_pause) { + return official; + } + + official.status = 'ready'; + official.umpire_wait = null; + official.service_judge_wait = null; + official.umpire_pause = null; + official.service_judge_pause = null; + official.umpire_manual_pause = null; + official.service_judge_manual_pause = null; + official.inactive_list = null; + + if (has_umpire_pause && (!has_service_judge_pause || umpire_pause <= service_judge_pause)) { + official.umpire_wait = umpire_pause; + } else { + official.service_judge_wait = service_judge_pause; + } + + sync_technical_official_checked_in(official, options.tournament || options.btp_settings); + return official; +} + +function apply_official_standby_state(official, options = {}) { + if (!official) { + return official; + } + + official.umpire_on_court = null; + official.service_judge_on_court = null; + official.is_planed_as_umpire = false; + official.is_planed_as_service_judge = false; + official.umpire_wait = null; + official.service_judge_wait = null; + official.umpire_pause = null; + official.service_judge_pause = null; + official.umpire_manual_pause = null; + official.service_judge_manual_pause = null; + official.inactive_list = null; + official.last_time_on_court_ts = null; + official.status = 'standby'; + official.court_id = null; + + sync_technical_official_checked_in(official, options.tournament || options.btp_settings); + return official; +} + +async function remove_umpire_on_court(app, tournament_key, cur_match_id, end_ts, callback) { + app.db.tournaments.findOne({ key: tournament_key }, (tournament_err, tournament) => { + if (tournament_err) { + return callback(tournament_err); + } + const official_rotation_mode = tournament?.official_rotation_mode || 'umpire_and_service_judge'; + const technical_official_break_after_assignment_ms = get_technical_official_break_after_assignment_ms(tournament); + + app.db.matches.findOne({ 'tournament_key': tournament_key, '_id': cur_match_id }, (err, cur_match) => { + if (err) { + return reject(err); + } + if (cur_match.setup.umpire) { + const umpire = apply_official_on_court_release(cur_match.setup.umpire, 'umpire', end_ts, { + tournament, + official_rotation_mode, + technical_official_break_after_assignment_ms, + }); + update_umpire(app, tournament_key, umpire, 'ready', end_ts, null); + } + + if (cur_match.setup.service_judge) { + const service_judge = apply_official_on_court_release(cur_match.setup.service_judge, 'service_judge', end_ts, { + tournament, + official_rotation_mode, + technical_official_break_after_assignment_ms, + }); + update_umpire(app, tournament_key, service_judge); + } + return callback(null); + }); + }); +} + +function set_umpire_to_standby(app, tournament_key, setup) { + app.db.tournaments.findOne({ key: tournament_key }, (tournament_err, tournament) => { + const standby_options = tournament_err || !tournament ? {} : { tournament }; + if (setup.umpire) { + const umpire = apply_official_standby_state(setup.umpire, standby_options); + update_umpire(app, tournament_key, umpire); + } + + if (setup.service_judge) { + const service_judge = apply_official_standby_state(setup.service_judge, standby_options); + update_umpire(app, tournament_key, service_judge); + } + }); +} + + + +function update_umpire(app, tkey, umpire) { + if (!umpire || !umpire._id) { + console.error('update_umpire: invalid umpire object'); + return; + } + + // Sicherheitsnetz: tournament_key immer korrekt setzen + umpire.tournament_key = tkey; + + app.db.umpires.update( + { _id: umpire._id, tournament_key: tkey }, + { $set: umpire }, + { returnUpdatedDocs: true }, + function (err, numAffected, changed_umpire) { + if (err) { + console.error(err); + return; + } + + const admin = require('./admin'); + admin.notify_change(app, tkey, 'umpire_updated', changed_umpire); + const official_is_waiting = + changed_umpire && + (changed_umpire.umpire_wait != null || changed_umpire.service_judge_wait != null); + + if (official_is_waiting) { + queue_auto_execute_preparation_selections(app, tkey); + } else { + queue_auto_assign_technical_officials_when_available(app, tkey); + } + } + ); +} + +function process_expired_technical_official_breaks_for_tournament(app, tournament, callback) { + if (!app || !app.db || !tournament || !tournament.key) { + return callback(null); + } + if ((tournament.official_rotation_mode || 'umpire_and_service_judge') === 'disabled') { + return callback(null); + } + const pause_ms = get_technical_official_break_after_assignment_ms(tournament); + const now = Date.now(); + const query = pause_ms > 0 + ? { + tournament_key: tournament.key, + $or: [ + { umpire_pause: { $ne: null, $lte: now } }, + { service_judge_pause: { $ne: null, $lte: now } }, + ] + } + : { + tournament_key: tournament.key, + $or: [ + { umpire_pause: { $ne: null } }, + { service_judge_pause: { $ne: null } }, + ] + }; + app.db.umpires.find(query, (err, officials) => { + if (err) { + return callback(err); + } + const sorted_officials = (officials || []).sort((a, b) => { + const a_ts = Math.min( + Number.isFinite(Number(a?.umpire_pause)) ? Number(a.umpire_pause) : Number.POSITIVE_INFINITY, + Number.isFinite(Number(a?.service_judge_pause)) ? Number(a.service_judge_pause) : Number.POSITIVE_INFINITY + ); + const b_ts = Math.min( + Number.isFinite(Number(b?.umpire_pause)) ? Number(b.umpire_pause) : Number.POSITIVE_INFINITY, + Number.isFinite(Number(b?.service_judge_pause)) ? Number(b.service_judge_pause) : Number.POSITIVE_INFINITY + ); + return a_ts - b_ts; + }); + + async.eachSeries(sorted_officials, (official, cb) => { + update_umpire(app, tournament.key, apply_official_pause_expiry(official, { tournament })); + cb(null); + }, callback); + }); +} + +function queue_process_expired_technical_official_breaks(app, tournament_key, callback) { + if (!app || !app.db || !tournament_key) { + if (callback) callback(null); + return; + } + + const pending = pending_technical_official_pause_runs.get(tournament_key); + if (pending) { + if (callback) { + pending.callbacks.push(callback); + } + return; + } + + const state = { + callbacks: callback ? [callback] : [], + }; + pending_technical_official_pause_runs.set(tournament_key, state); + + update_queue.instance().execute( + update_queue.named(`technical_official_pause_expiry_${tournament_key}`, () => new Promise((resolve, reject) => { + app.db.tournaments.findOne({ key: tournament_key }, (err, tournament) => { + if (err || !tournament) { + reject(err || new Error('tournament not found')); + return; + } + process_expired_technical_official_breaks_for_tournament(app, tournament, (process_err) => { + if (process_err) { + reject(process_err); + return; + } + resolve(null); + }); + }); + })) + ).then(() => { + const current = pending_technical_official_pause_runs.get(tournament_key); + pending_technical_official_pause_runs.delete(tournament_key); + (current?.callbacks || []).forEach((cb) => cb(null)); + }).catch((err) => { + const current = pending_technical_official_pause_runs.get(tournament_key); + pending_technical_official_pause_runs.delete(tournament_key); + (current?.callbacks || []).forEach((cb) => cb(err)); + }); +} + +function start_technical_official_pause_manager(app) { + if (technical_official_pause_interval || !app || !app.db) { + return; + } + technical_official_pause_interval = setInterval(() => { + app.db.tournaments.find({ + official_rotation_mode: { $ne: 'disabled' }, + technical_official_break_after_assignment_seconds: { $gt: 0 }, + }, (err, tournaments) => { + if (err) { + return; + } + (tournaments || []).forEach((tournament) => { + queue_process_expired_technical_official_breaks(app, tournament.key); + }); + }); + }, 1000); +} + +function call_preparation_match_on_court(app, tournament_key, court_id) { + return new Promise((resolve, reject) => { + console.log('[bts] auto_call_trace:call_preparation_match_on_court_start', { + ts: Date.now(), + tournament_key, + court_id, + }); + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return reject("No tournament found for "); + } + if (!is_tournament_automation_enabled(tournament)) { + return resolve("Global automation disabled"); + } + if (tournament.call_next_possible_scheduled_match_in_preparation) { + const match_automation = require('./match_automation'); + Promise.all([ + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + app.db.umpires.find_async({ tournament_key }), + ]).then(([courts, matches, umpires]) => { + const current_tournament = { + ...(tournament || {}), + courts, + matches, + umpires, + }; + const candidates = match_automation.find_call_on_court_candidates(current_tournament, court_id); + if (!candidates || candidates.length === 0) { + console.log('[bts] auto_call_trace:call_preparation_match_on_court_no_candidate', { + ts: Date.now(), + tournament_key, + court_id, + }); + return reject("No match found to call on court."); + } + const next_match = candidates[0]; + console.log('[bts] auto_call_trace:call_preparation_match_on_court_candidate', { + ts: Date.now(), + tournament_key, + court_id, + match_id: next_match && next_match._id, + state: next_match && next_match.setup && next_match.setup.state, + highlight: next_match && next_match.setup && next_match.setup.highlight, + location_id: next_match && next_match.setup && next_match.setup.location_id, + candidate_count: candidates.length, + }); + next_match.setup.court_id = court_id; + next_match.setup.now_on_court = true; + call_match(app, tournament, next_match, undefined, (callErr) => { + if (callErr) { + return reject(callErr); + } + return resolve(next_match); + }); + }).catch((queryErr) => reject(queryErr)); + } else { + return resolve("Function call_next_possible_scheduled_match_in_preparation disabled"); + } + }); + }); +} + +function auto_call_matches_on_free_courts(app, tournament_key, callback) { + if (!app || !app.db || !tournament_key) { + return callback ? callback(null) : null; + } + + app.db.tournaments.findOne({ key: tournament_key }, (tournamentErr, tournament) => { + if (tournamentErr) { + return callback ? callback(tournamentErr) : null; + } + + app.db.courts.find({ tournament_key, is_active: true }, (err, courts) => { + if (err) { + return callback ? callback(err) : null; + } + + app.db.matches.find({ tournament_key, 'setup.now_on_court': true }, (matchesErr, on_court_matches) => { + if (matchesErr) { + return callback ? callback(matchesErr) : null; + } + + const continue_with_tabletoperators = (tabletoperators) => { + const occupied_court_ids = new Set( + (on_court_matches || []) + .filter((match) => match?.setup?.court_id) + .map((match) => match.setup.court_id) + ); + const free_active_courts = sort_free_courts_for_auto_call( + (courts || []).filter((court) => court && !occupied_court_ids.has(court._id)), + tabletoperators, + tournament + ); + console.log('[bts] auto_call_trace:auto_call_matches_on_free_courts', { + ts: Date.now(), + tournament_key, + active_court_ids: (courts || []).filter((court) => court && court.is_active).map((court) => court._id), + occupied_court_ids: Array.from(occupied_court_ids), + free_active_court_ids: free_active_courts.map((court) => court._id), + }); + + async.eachSeries(free_active_courts, (court, cb) => { + call_preparation_match_on_court(app, tournament_key, court._id) + .then(() => cb(null)) + .catch((callErr) => { + const message = callErr && (callErr.message || String(callErr)); + if (/No match found to call on court/.test(message)) { + return cb(null); + } + return cb(callErr); + }); + }, (finalErr) => { + if (callback) { + callback(finalErr); + } + }); + }; + + if (tournament?.tabletoperator_enabled !== true) { + return continue_with_tabletoperators([]); + } + + app.db.tabletoperators.find({ tournament_key, court: null }, (tabletErr, tabletoperators) => { + if (tabletErr) { + return callback ? callback(tabletErr) : null; + } + return continue_with_tabletoperators(tabletoperators || []); + }); + }); + }); + }); +} + +async function call_next_possible_match_for_preparation(app, tournament_key, callback) { + app.db.tournaments.findOne({ key: tournament_key }, async (err, tournament) => { + if (err) { + return callback("No tournament found for "); + } + if (tournament.call_next_possible_scheduled_match_in_preparation) { + const match_querry = { 'tournament_key': tournament_key, 'setup.state': 'scheduled' }; + app.db.matches.find(match_querry).sort({ 'setup.scheduled_date': 1, 'setup.scheduled_time_str': 1, 'match_order': 1 }).exec((err, matches) => { + if (err) { + return callback(err); + } + if (matches && matches.length > 0) { + const now = new Date(); + for (var i = 0; i < matches.length; ++i) { + var match = matches[i]; + var possible = true; + for (let team_index = 0; team_index < Math.min(match.setup.teams.length, match.setup.teams.length); team_index++) { + for (let player_index = 0; player_index < Math.min(match.setup.teams[team_index].players.length, match.setup.teams[team_index].players.length); player_index++) { + if (possible == true) { + const player = match.setup.teams[team_index].players[player_index]; + if (player.now_playing_on_court != undefined) { + if (player.now_playing_on_court === false) { + possible = true; + } else { + possible = false; + } + } + if (possible) { + if (player.now_tablet_on_court != undefined) { + if (player.now_tablet_on_court === false) { + possible = true; + } else { + possible = false; + } + } + if (possible) { + if (player.last_time_on_court_ts) { + const last_time_on_court = new Date(player.last_time_on_court_ts); + if ((now - last_time_on_court) < tournament.btp_settings.pause_duration_ms) { + possible = false; + } else { + possible = true; + } + } + } + } + } + } + } + if (possible) { + call_match_in_preparation(app, tournament,match._id, null, match.setup, callback); + break; + } + } + } else { + return callback("No match found to call on court."); + } + }); + } else { + return callback(null); + } + }); +} + + +async function call_match_in_preparation(app, tournament, match, location_id, callback, options = {}) { + const tournament_key = tournament.key; + const admin = require('./admin'); + const setup = match.setup; + const match_id = match._id; + const force = options && options.force === true; + + app.db.matches.findOne({ _id: match_id, tournament_key }, async (findErr, current_match) => { + if (findErr) { + return callback(findErr); + } + if (!current_match) { + return callback(new Error('Cannot find match ' + match_id + ' of tournament ' + tournament_key + ' in database')); + } + if ( + current_match.setup && + current_match.setup.state === 'preparation' && + Number(current_match.setup.highlight) > 0 + ) { + return callback(null); + } + + if (!force) { + try { + const match_automation = require('./match_automation'); + const [courts, matches, umpires] = await Promise.all([ + app.db.courts.find_async({ tournament_key }), + app.db.matches.find_async({ tournament_key }), + app.db.umpires.find_async({ tournament_key }), + ]); + const current_tournament = { + ...(tournament || {}), + courts, + matches, + umpires, + }; + if (!match_automation.is_match_eligible_for_preparation(current_match, location_id, current_tournament)) { + return callback(null); + } + } catch (eligibilityErr) { + return callback(eligibilityErr); + } + } + + await add_preparation_call_timestamp(app.db, tournament_key, setup, location_id); + + if (is_tournament_automation_enabled(tournament) && tournament.preparation_tabletoperator_setup_enabled) { + if (!setup.umpire || (tournament.tabletoperator_with_umpire_enabled && tournament.tabletoperator_with_umpire_enabled == true)) { + if (!setup.tabletoperators || setup.tabletoperators == null) { + const fetch_result = await fetch_tabletoperator(admin, app, tournament.key, "prep_call"); + let value = []; + if (tournament.tabletoperator_with_state_from_match_enabled && typeof(fetch_result) == "undefined") { + value.push({ + asian_name: false, + name: setup.teams[0].players[0].state, + firstname: "", + lastname: "", + btp_id: -1}); + } else { + value = fetch_result; + } + + if (!setup.umpire || !setup.umpire.name || (tournament.tabletoperator_with_umpire_enabled && tournament.tabletoperator_with_umpire_enabled == true)) { + setup.tabletoperators = value; + } + } + } + } + set_umpire_to_standby(app, tournament_key, setup); + + app.db.matches.update({ _id: match_id, tournament_key }, { $set: { setup } }, { returnUpdatedDocs: true }, function (err, numAffected, changed_match) { + if (err) { + return callback(err); + } + if (numAffected !== 1) { + return callback(new Error('Cannot find match ' + match_id + ' of tournament ' + tournament_key + ' in database')); + } + if (changed_match._id !== match_id) { + const errmsg = 'Match ' + changed_match._id + ' changed by accident, intended to change ' + match_id + ' (old nedb version?)'; + serror.silent(errmsg); + + return callback(new Error(errmsg)); + } + return auto_assign_technical_officials_for_match(app, tournament, match_id, (assignErr) => { + if (assignErr) { + return callback(assignErr); + } + app.db.matches.findOne({ _id: match_id, tournament_key }, (latestErr, latest_match) => { + if (latestErr) { + return callback(latestErr); + } + const final_match = latest_match || changed_match; + admin.notify_change(app, tournament_key, 'match_preparation_call', { match__id: match_id, match: final_match}); + const btp_manager = require('./btp_manager'); + btp_manager.update_highlight(app, final_match); + return callback(null); + }); + }); + }); + }); +} + +function update_btp_courts(app, tournament_key, match, callback) { + const stournament = require('./stournament'); + const btp_manager = require('./btp_manager'); + stournament.get_courts(app.db, tournament_key, (err, all_courts) => { + if (err) { + callback(err); + return; + } + + const courts = []; + + all_courts.forEach((element, index) => { + if (match.setup.court_id === element._id && match.setup.now_on_court) { + const court = { + btp_id: element.btp_id, + btp_match_id: match.btp_match_ids[0].id, + } + + courts.push(court); + } else if (element.match_id && element.match_id == ("btp_" + match.btp_id) && !match.setup.now_on_court) { + const court = { + btp_id: element.btp_id + } + + courts.push(court); + } + }); + + btp_manager.update_courts(app, tournament_key, courts); + + callback(null); + return; + }); +} +function reset_player_tabletoperator(app, tournament_key, match_id, end_ts) { + return new Promise((resolve, reject) => { + let current_match = null; + async.waterfall([ + cb => fetch_match(app, tournament_key, match_id).then((match) => { + current_match = match; + cb(null); + }).catch(cb), + cb => remove_player_on_court(app, tournament_key, match_id, end_ts, cb), + cb => remove_tablet_on_court(app, tournament_key, match_id, end_ts, cb), + cb => remove_umpire_on_court(app, tournament_key, match_id, end_ts, cb), + cb => add_player_to_tabletoperator_list(app, tournament_key, match_id, end_ts, cb), + cb => { + if (!current_match) { + return cb(null); + } + update_btp_courts(app, tournament_key, current_match, cb); + }, + ], function (err) { + if (err) { + return reject(err); + } + return resolve(null); + }); + }); +} + +module.exports ={ + add_player_to_tabletoperator_list, + call_match, + calc_match_set_player_on_court, + calc_match_set_player_on_tablet, + switch_court, + match_update, + uncall_match, + fetch_match, + fetch_tabletoperator, + match_completly_initialized, + remove_player_on_court, + remove_tablet_on_court, + remove_umpire_on_court, + set_player_on_court, + set_player_on_tablet, + set_umpire_to_standby, + add_preparation_call_timestamp, + remove_preparation_call_timestamp, + normalize_preparation_state, + reset_player_tabletoperator, + apply_official_on_court_release, + apply_official_pause_expiry, + apply_official_standby_state, + auto_execute_preparation_selection, + auto_execute_preparation_selections, + auto_execute_preparation_selection_for_setup, + queue_auto_execute_preparation_selections, + technical_official_auto_assignment_mode_supports_preparation, + auto_assign_technical_officials_for_match, + fetch_technical_official_assignment_targets, + auto_assign_technical_officials_for_preparation_matches, + queue_auto_assign_technical_officials_when_available, + get_technical_official_break_after_assignment_ms, + process_expired_technical_official_breaks_for_tournament, + queue_process_expired_technical_official_breaks, + start_technical_official_pause_manager, + auto_call_matches_on_free_courts, + call_preparation_match_on_court, + call_next_possible_match_for_preparation, + call_match_in_preparation, + sort_free_courts_for_auto_call, + is_tournament_automation_enabled, + is_technical_official_unavailable, + get_effective_technical_official_checked_in, + sync_technical_official_checked_in +}; diff --git a/bts/stournament.js b/bts/stournament.js index 6ff3dc5..dd623d2 100644 --- a/bts/stournament.js +++ b/bts/stournament.js @@ -4,6 +4,18 @@ const utils = require('./utils'); +function get_locations(db, tournament_key, callback) { + db.locations.find({tournament_key}, function(err, locations) { + if (err) return callback(err); + + locations.sort(function(l1, l2) { + return utils.natcmp(('' + l1.btp_id), ('' + l2.btp_id)); + }); + return callback(err, locations); + }); +} + + function get_courts(db, tournament_key, callback) { db.courts.find({tournament_key}, function(err, courts) { if (err) return callback(err); @@ -31,9 +43,58 @@ function get_matches(db, tournament_key, callback) { }); } +function get_tabletoperators(db, tournament_key, callback) { + db.tabletoperators.find({ tournament_key }, function (err, tabletoperators) { + if (err) return callback(err); + return callback(err, tabletoperators); + }); +} + +function get_displays(app, tournament, callback) { + app.db.display_court_displaysettings.find({}, function (err, display_court_displaysettings) { + if (err) return callback(err); + + // TODO: Append not registered Displays and set status online/offline of registered displays by using ite registered ws in bubws + display_court_displaysettings = display_court_displaysettings.filter(function (obj) { + return obj.client_id !== 'deleted'; + }); + + const bupws = require('./bupws'); + bupws.add_display_status(app, tournament, display_court_displaysettings, function (display_court_displaysettings) { + display_court_displaysettings = display_court_displaysettings.sort(utils.cmp_key('client_id')); + return callback(err, display_court_displaysettings); + }); + }); +} + +function get_normalizations(db, tournament_key, callback) { + db.normalizations.find({}, function (err, normalizations) { + if (err) return callback(err); + return callback(err, normalizations); + }); +} +function get_advertisements(db, tournament_key, callback) { + db.advertisements.find({}, function (err, advertisements) { + if (err) return callback(err); + return callback(err, advertisements); + }); +} + +function get_displaysettings(db, tournament_key, callback) { + db.displaysettings.find({}, function (err, displaysettings) { + if (err) return callback(err); + return callback(err, displaysettings); + }); +} module.exports = { + get_locations, get_courts, get_matches, get_umpires, + get_tabletoperators, + get_displays, + get_normalizations, + get_displaysettings, + get_advertisements, }; diff --git a/bts/ticker_conn.js b/bts/ticker_conn.js index 91b6079..e331d85 100644 --- a/bts/ticker_conn.js +++ b/bts/ticker_conn.js @@ -6,7 +6,8 @@ const ws_module = require('ws'); const utils = require('./utils'); const serror = require('./serror'); - +const fs = require('fs').promises; +const path = require('path'); const RECONNECT_TIMEOUT = 1000; @@ -17,7 +18,7 @@ function craft_court(c) { function craft_match(m) { const res = utils.pluck(m, ['_id']); res.s = m.network_score; - res.c = m.setup.counting; + res.sf = m.setup.scoring_format; res.n = m.setup.event_name + ' ' + m.setup.match_name; m.setup.teams.forEach((t, tidx) => { res['p' + tidx] = t.players.map(p => p.name); @@ -43,9 +44,9 @@ class TickerConn { return; } - this.report_status('Verbindung wird hergestellt ...'); + this.report_status('connecting','Verbindung wird hergestellt ...'); if (!/^wss?:\/\/.*\/update/.test(this.url)) { - this.report_status('Ungültige Ticker-URL: ' + JSON.stringify(this.url)); + this.report_status('error','Ungültige Ticker-URL: ' + JSON.stringify(this.url)); return; } const ws_url = this.url + '?password=' + encodeURIComponent(this.password); @@ -53,7 +54,7 @@ class TickerConn { const tc = this; tc.ws = ws; ws.on('open', function() { - tc.report_status('Connected.'); + tc.report_status('connected',''); tc.pushall(); }); ws.on('message', function(data) { @@ -61,11 +62,11 @@ class TickerConn { try { msg = JSON.parse(data); } catch (e) { - tc.report_status('Failed to receive ticker message: ' + e.message); + tc.report_status('error', 'Failed to receive ticker message: ' + e.message); return; } if ((msg.type === 'error') || ((msg.type === 'dmsg') && (msg.dtype === 'error'))) { - tc.report_status('Error: ' + msg.message); + tc.report_status('error', msg.message); } }); ws.on('error', function() { @@ -91,7 +92,7 @@ class TickerConn { ws.close(); } this.terminated = true; - this.report_status('Ended.'); + this.report_status('deactivated'); } schedule_reconnect() { @@ -105,7 +106,7 @@ class TickerConn { this._craft_event((err, event) => { if (err) { serror.silent('Failed to craft event: ' + err.message + ' ' + err.stack); - this.report_status('Failed to craft data'); + this.report_status('error','Failed to craft data'); return; } @@ -140,11 +141,15 @@ class TickerConn { on_end() { this.ws = null; - this.report_status('Verbindung verloren, versuche erneut ...'); + this.report_status('error','Verbindung verloren, versuche erneut ...'); this.schedule_reconnect(); } - report_status(msg) { + report_status(status, message) { + const msg = { + status: status, + message: message + } this.last_status = msg; const admin = require('./admin'); admin.notify_change(this.app, this.tournament_key, 'ticker_status', msg); @@ -160,7 +165,10 @@ class TickerConn { }, { collection: 'matches', query: {tournament_key}, - }], (err, db_courts, db_matches) => { + }, { + collection: 'tournaments', + query: { key: tournament_key}, + }], (err, db_courts, db_matches, db_tournaments) => { if (err) return cb(err); const interesting_ids = utils.filter_map(db_courts, c => c.match_id); @@ -183,10 +191,59 @@ class TickerConn { } } - return cb(null, { - courts: db_courts.map(craft_court), - matches: interesting_matches.map(craft_match), - }); + if (db_tournaments && db_tournaments.length == 1) { + const tournament = db_tournaments[0]; + const tname = tournament.name; + const turl = "https://" + ((tournament.btp_settings && tournament.btp_settings.tournament_urn) ? tournament.btp_settings.tournament_urn : "www.turnier.de") + "/tournament" + (tournament.tguid ? "/" + tournament.tguid + "/matches" : "s/"); + if (tournament.logo_id && tournament.logo_id != null) { + const file_path = path.join(utils.root_dir(), 'data', 'logos', tournament.logo_id); + fs.readFile(file_path) + .then((file_buffer) => { + const base64_image = file_buffer.toString('base64'); + const filetype = tournament.logo_id.split(".")[1]; + const mime = { + gif: 'image/gif', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + svg: 'image/svg+xml', + webp: 'image/webp', + }[filetype]; + + return cb(null, { + courts: db_courts.map(craft_court), + matches: interesting_matches.map(craft_match), + tournament_name: tname, + tournament_url: turl, + tournament_logo: base64_image, + tournament_logo_mime: mime, + tournament_logo_background_color: tournament.logo_background_color + }); + }) + .catch((error) => { + return cb(null, { + courts: db_courts.map(craft_court), + matches: interesting_matches.map(craft_match), + tournament_name: tname, + tournament_url: turl + }); + }); + } else { + return cb(null, { + courts: db_courts.map(craft_court), + matches: interesting_matches.map(craft_match), + tournament_name: tname, + tournament_url: turl + }); + } + } else { + return cb(null, { + courts: db_courts.map(craft_court), + matches: interesting_matches.map(craft_match), + tournament_name: "", + tournament_url: "" + }); + } }); } diff --git a/bts/ticker_manager.js b/bts/ticker_manager.js index 4acc258..b5c34ab 100644 --- a/bts/ticker_manager.js +++ b/bts/ticker_manager.js @@ -71,9 +71,8 @@ function init(app, cb) { function get_status(tkey) { const conn = conns_by_tkey.get(tkey); if (!conn) { - return 'deactivated.'; + return { status: 'deactivated', message: '' }; } - return conn.last_status; } diff --git a/bts/update_queue.js b/bts/update_queue.js new file mode 100644 index 0000000..6977dc4 --- /dev/null +++ b/bts/update_queue.js @@ -0,0 +1,119 @@ +'use strict'; + +class update_queue { + constructor() { + this.queue = []; + this.active = false; + this.current_task = null; + this.current_task_started_at = null; + this.current_task_watchdog = null; + this.hang_reporter = null; + } + + _task_name(task) { + if (!task) { + return ''; + } + if (task._queue_name) { + return task._queue_name; + } + return task.name && task.name.length > 0 ? task.name : ''; + } + + _task_hang_after_ms(task) { + if (!task) { + return 5000; + } + if (typeof task._queue_hang_after_ms === 'number' && task._queue_hang_after_ms > 0) { + return task._queue_hang_after_ms; + } + return 5000; + } + + _start_watchdog(task, task_name) { + this._clear_watchdog(); + const hang_after_ms = this._task_hang_after_ms(task); + this.current_task_watchdog = setTimeout(() => { + const runtime_ms = this.current_task_started_at ? (Date.now() - this.current_task_started_at) : null; + const payload = { + task: task_name, + runtime_ms, + queue_length: this.queue.length + }; + console.warn('[bts] update_queue:task_still_running', payload); + if (typeof this.hang_reporter === 'function') { + try { + this.hang_reporter(payload); + } catch (err) { + console.warn('[bts] update_queue:hang_reporter_error', err && (err.stack || err.message || String(err))); + } + } + }, hang_after_ms); + } + + _clear_watchdog() { + if (this.current_task_watchdog) { + clearTimeout(this.current_task_watchdog); + this.current_task_watchdog = null; + } + } + + async process() { + if (this.active) { + return; + } + this.active = true; + while (this.queue.length > 0) { + const { task, args, resolve, reject } = this.queue.shift(); + const task_name = this._task_name(task); + this.current_task = task_name; + this.current_task_started_at = Date.now(); + this._start_watchdog(task, task_name); + try { + const res = await task(...args); + this._clear_watchdog(); + this.current_task = null; + this.current_task_started_at = null; + resolve(res); + } catch (err) { + this._clear_watchdog(); + this.current_task = null; + this.current_task_started_at = null; + reject(err); + } + } + this.active = false; + } + + async execute(task, ...args) { + return new Promise((resolve, reject) => { + this.queue.push({ task, args, resolve, reject }); + this.process(); + }); + } + + set_hang_reporter(fn) { + this.hang_reporter = fn; + } +} +const update_queue_inst = new update_queue(); + +function instance() { + return update_queue_inst; +} + +function named(name, task) { + task._queue_name = name; + return task; +} + +function hang_after(ms, task) { + task._queue_hang_after_ms = ms; + return task; +} + +module.exports = { + instance, + named, + hang_after +}; diff --git a/bts/utils.js b/bts/utils.js index e05318e..cc2e9a3 100644 --- a/bts/utils.js +++ b/bts/utils.js @@ -43,6 +43,9 @@ function cmp_key(key) { return function(o1, o2) { const v1 = o1[key]; const v2 = o2[key]; + if(!isNaN(Number(v1) && !isNaN(v2))) { + return cmp(Number(v1), Number(v2)); + } return cmp(v1, v2); }; } @@ -202,6 +205,13 @@ function format_ts(ts) { ); } +function format_time_ts(ts) { + var d = new Date(ts); + return ( + pad(d.getHours(), 2) + ':' + pad(d.getMinutes(), 2) + ); +} + function has_key(obj, testfunc) { for (const k in obj) { if (testfunc(k)) return true; @@ -218,6 +228,7 @@ function get_system_timezone() { return _cached_timezone; } + module.exports = { cmp, cmp_key, @@ -225,6 +236,7 @@ module.exports = { deep_equal, filter_map, format_ts, + format_time_ts, encode_html, gen_token, get_system_timezone, @@ -238,5 +250,5 @@ module.exports = { remove, root_dir, size, - values, + values }; diff --git a/bts/wshandler.js b/bts/wshandler.js index 633e10e..d0b571e 100644 --- a/bts/wshandler.js +++ b/bts/wshandler.js @@ -46,6 +46,9 @@ function handle(mod, app, ws) { serror.silent('Received error message from client: ' + msg.message); return; } + if (msg.tournament_key) { + ws.last_tournament_key = msg.tournament_key; + } const func = mod['handle_' + msg.type]; if (func) { @@ -83,4 +86,4 @@ function handle(mod, app, ws) { module.exports = { handle, -}; \ No newline at end of file +}; diff --git a/config.json.default b/config.json.default index ab82eb5..0761365 100644 --- a/config.json.default +++ b/config.json.default @@ -2,5 +2,9 @@ "port": 4000, "bup_location": "static/bup/downloaded/", "bup_index": "index.html", - "report_errors": true + "report_errors": false, + "enable_https": false, + "https_port": 4433, + "https_key": "${PATH_TO}/key.pem", + "https_cert": "${PATH_TO}/cert.pem" } \ No newline at end of file diff --git a/fetch-btp.js b/fetch-btp.js index 8a2e6c3..366bfba 100755 --- a/fetch-btp.js +++ b/fetch-btp.js @@ -286,7 +286,7 @@ async function main() { const btp_id = tkey + '_' + discipline_name + '_' + match_num; const match = btp_sync.craft_match( - tkey, btp_id, pseudo_court_map, event, draw, officials, bm, match_ids_on_court); + btp_conn.app, tkey, btp_id, pseudo_court_map, event, draw, officials, bm, match_ids_on_court, []); if (!match) { continue; } diff --git a/install-service.sh b/install-service.sh new file mode 100644 index 0000000..0b9f8f6 --- /dev/null +++ b/install-service.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Check if node is installed +if ! command -v node >/dev/null 2>&1; then + +# If not, install it + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash + \. "$HOME/.nvm/nvm.sh" + nvm install 18 +fi + +# Install BTS to home +cd ~ +if [ ! -d "bts" ]; then + git clone https://github.com/tlehr/bts.git +fi + +cd bts +git fetch +git switch feat/automaticCall +make deps + +# Install BUP to home +cd ~ + +if [ ! -d "bup" ]; then + git clone https://github.com/tlehr/bup.git +fi + +cd bup +git fetch +git switch feat/addTimerAfterCall +make deps + +# Use Development BUP in BTS +cd ~/bts || exit 1 + +cat > config.json <<'EOF' +{ + "port": 4000, + "bup_location": "static/bup/dev", + "bup_index": "bup.html", + "report_errors": true, + "enable_https": false +} +EOF + +# Create symlink +ln -s ~/bup/ ~/bts/static/bup/dev + +# use node version that works +nvm install 22.18.0 +node_path="$(which node)" + +# copy used node version into service template +cat > "$HOME/bts/div/bts.service.template" <=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", @@ -1016,6 +1025,104 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone-regexp": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-2.2.0.tgz", @@ -1269,7 +1376,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -1413,6 +1519,11 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/dir-glob": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", @@ -1510,6 +1621,11 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -2335,7 +2451,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -4260,7 +4375,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", - "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -4284,7 +4398,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "engines": { "node": ">=6" } @@ -4437,6 +4550,14 @@ "node": ">=6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -4700,6 +4821,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", + "integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "encode-utf8": "^1.0.3", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", @@ -4999,7 +5137,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5007,8 +5144,7 @@ "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, "node_modules/resolve": { "version": "1.11.1", @@ -5190,8 +5326,7 @@ "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, "node_modules/set-value": { "version": "2.0.1", @@ -6385,8 +6520,7 @@ "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", - "dev": true + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" }, "node_modules/wide-align": { "version": "1.1.3", @@ -6545,8 +6679,28 @@ "node_modules/y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", - "dev": true + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } }, "node_modules/yargs-parser": { "version": "13.0.0", @@ -6558,15 +6712,6 @@ "decamelize": "^1.2.0" } }, - "node_modules/yargs-parser/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yargs-unparser": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.5.0.tgz", @@ -6581,15 +6726,6 @@ "node": ">=6" } }, - "node_modules/yargs-unparser/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yargs-unparser/node_modules/cliui": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", @@ -6643,6 +6779,105 @@ "decamelize": "^1.2.0" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/yauzl": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", @@ -7350,6 +7585,11 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, "camelcase-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", @@ -7481,6 +7721,82 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, "clone-regexp": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-2.2.0.tgz", @@ -7681,8 +7997,7 @@ "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, "decamelize-keys": { "version": "1.1.0", @@ -7791,6 +8106,11 @@ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, + "dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "dir-glob": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", @@ -7879,6 +8199,11 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==" + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -8545,8 +8870,7 @@ "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-stdin": { "version": "7.0.0", @@ -10102,7 +10426,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", - "dev": true, "requires": { "p-try": "^2.0.0" } @@ -10119,8 +10442,7 @@ "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, "parent-module": { "version": "1.0.1", @@ -10239,6 +10561,11 @@ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true }, + "pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -10442,6 +10769,17 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "qrcode": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", + "integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==", + "requires": { + "dijkstrajs": "^1.0.1", + "encode-utf8": "^1.0.3", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + } + }, "qs": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", @@ -10686,14 +11024,12 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, "resolve": { "version": "1.11.1", @@ -10848,8 +11184,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, "set-value": { "version": "2.0.1", @@ -11838,8 +12173,7 @@ "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", - "dev": true + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" }, "wide-align": { "version": "1.1.3", @@ -11963,8 +12297,99 @@ "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", - "dev": true + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } }, "yargs-parser": { "version": "13.0.0", @@ -11974,14 +12399,6 @@ "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - } } }, "yargs-unparser": { @@ -11995,12 +12412,6 @@ "yargs": "^12.0.5" }, "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - }, "cliui": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", diff --git a/package.json b/package.json index 4a2a34f..882928a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "nedb": "*", "node-xlsx": "^0.21.0", "pretty-data": "^0.40.0", + "qrcode": "^1.5.3", "request": "^2.88.0", "rimraf": "*", "serve-favicon": "*", diff --git a/reset-bts.sh b/reset-bts.sh new file mode 100755 index 0000000..c5a42b1 --- /dev/null +++ b/reset-bts.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd "$(dirname "$(realpath "$0")")" + +sudo systemctl stop bts +rm -rf ./data/* +cp -rf ./blank_tournament/* ./data/ +sudo systemctl start bts diff --git a/static/audio/evakuierung.mp3 b/static/audio/evakuierung.mp3 new file mode 100644 index 0000000..5c59e4b Binary files /dev/null and b/static/audio/evakuierung.mp3 differ diff --git a/static/cbts.html b/static/cbts.html index 6222446..c64482d 100644 --- a/static/cbts.html +++ b/static/cbts.html @@ -1,85 +1,87 @@ - -Badminton Tournament Server - - - - - - - + + Badminton Tournament Server + + + + + + + + -
Connecting ...
+
Connecting ...
-
+
-
+
-
-
-
-
- - + + - - - - - + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + - - - - + + + + + - - + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/static/css/admin.css b/static/css/admin.css index 323249e..b969b16 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -1,5 +1,27 @@ -html, -body { +td.battery-status-green { + background: linear-gradient(90deg, hsl(92, 89%, 46%) 15%, hsl(92, 90%, 68%) 100%); +} +td.battery-status-yellow { + background: linear-gradient(90deg, hsl(54, 89%, 46%) 15%, hsl(92, 90%, 45%) 100%); +} +td.battery-status-orange { + background: linear-gradient(90deg, hsl(22, 89%, 46%) 15%, hsl(54, 90%, 45%) 100%); + color: white; +} +td.battery-status-red { + background: linear-gradient(90deg, hsl(7, 89%, 46%) 15%, hsl(11, 93%, 68%) 100%); + color: white; +} + +td.battery-status-charging { + background-image: url(../icons/charging.svg); + background-repeat: no-repeat; + background-position: center center; + background-color: forestgreen; + color: white; + font-weight: bold; +} +html, body { margin: 0; padding: 0; font-size: 20px; @@ -14,14 +36,21 @@ select { } .main { - padding: 0 0 0 1em; + padding: 0 0 0 0; } h1 { margin: 0; } h3 { - margin: 0; + margin: 20px 0 10px 0; +} + +button { + margin-right: 5px; + margin-left: 5px; + margin-top: 2px; + margin-bottom: 2px; } .connecting { @@ -33,37 +62,510 @@ h3 { background: rgba(255, 255, 255, 0.8); font-size: 50px; } - +.status_label, .status { - position: fixed; - bottom: 0; - right: 0; color: #666; - background: rgba(255, 255, 255, 0.8); } +.btp_status_label, .btp_status { - position: fixed; - bottom: 1.5em; - right: 0; color: #666; - background: rgba(255, 255, 255, 0.8); } +.ticker_status_label, .ticker_status { - position: fixed; - bottom: 3em; - right: 0; color: #666; - background: rgba(255, 255, 255, 0.8); } +.metadata_container{ + width: 100%; +} -.errors { - position: fixed; - bottom: 0; +.metadata_right_container{ + min-width: 550px; + width: 20%; +} + +.metadata_right_top_container{ + display:flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5em 1em; +} + +.automation_controls_panel { + min-width: 260px; + padding: 0.15em 0.25em 0.25em 0.25em; + display: flex; + justify-content: center; +} + +.automation_orbit { + position: relative; + width: 140px; + height: 140px; + border-radius: 50%; + overflow: hidden; + border: 2px solid #000; + background: + conic-gradient( + from -45deg, + var(--automation-segment-top, #101010) 0deg 90deg, + var(--automation-segment-right, #101010) 90deg 180deg, + var(--automation-segment-bottom, #101010) 180deg 270deg, + var(--automation-segment-left, #101010) 270deg 360deg + ); +} + +.automation_orbit.is-global-active { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 3px 10px rgba(0, 0, 0, 0.18), + 0 10px 18px rgba(0, 0, 0, 0.12); +} + +.automation_orbit.is-paused-global { + filter: saturate(0.72) brightness(0.82); + box-shadow: + 0 0 0 2px rgba(150, 24, 24, 0.34), + 0 4px 14px rgba(70, 0, 0, 0.28), + 0 12px 22px rgba(55, 0, 0, 0.2); +} + +.automation_orbit::before, +.automation_orbit::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + pointer-events: none; +} + +.automation_orbit::before { + background: + linear-gradient(45deg, + transparent calc(50% - 2.5px), + rgba(255, 255, 255, 0.34) calc(50% - 2.5px), + rgba(0, 0, 0, 0.56) calc(50% + 2.5px), + transparent calc(50% + 2.5px) + ), + linear-gradient(-45deg, + transparent calc(50% - 2.5px), + rgba(255, 255, 255, 0.3) calc(50% - 2.5px), + rgba(0, 0, 0, 0.5) calc(50% + 2.5px), + transparent calc(50% + 2.5px) + ), + radial-gradient(circle at 32% 22%, + rgba(255, 255, 255, 0.28) 0%, + rgba(255, 255, 255, 0.16) 16%, + transparent 40% + ), + radial-gradient(circle at 66% 70%, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 14%, + transparent 30% + ), + radial-gradient(circle at 72% 78%, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.04) 16%, + transparent 36% + ); + z-index: 2; +} + +.automation_orbit::after { + background: + radial-gradient(circle at 50% 50%, + transparent 54%, + rgba(255, 255, 255, 0.1) 70%, + rgba(0, 0, 0, 0.12) 100% + ); + box-shadow: + inset 0 3px 5px rgba(255, 255, 255, 0.32), + inset 0 -5px 8px rgba(0, 0, 0, 0.28), + inset 2px 0 3px rgba(255, 255, 255, 0.1), + inset -2px 0 3px rgba(0, 0, 0, 0.12); + z-index: 1; +} + +.automation_orbit.is-paused-global::after { + background: + radial-gradient(circle at 50% 50%, + rgba(130, 20, 20, 0.08) 0%, + rgba(90, 0, 0, 0.16) 62%, + rgba(40, 0, 0, 0.28) 100% + ), + rgba(64, 0, 0, 0.28); + box-shadow: + inset 0 3px 5px rgba(255, 210, 210, 0.18), + inset 0 -6px 10px rgba(30, 0, 0, 0.56), + inset 2px 0 3px rgba(255, 235, 235, 0.05), + inset -2px 0 3px rgba(45, 0, 0, 0.22); +} + +.automation_orbit.is-paused-global::before { + background: + linear-gradient(45deg, + transparent calc(50% - 3px), + rgba(255, 210, 210, 0.2) calc(50% - 3px), + rgba(40, 0, 0, 0.78) calc(50% + 3px), + transparent calc(50% + 3px) + ), + linear-gradient(-45deg, + transparent calc(50% - 3px), + rgba(255, 210, 210, 0.16) calc(50% - 3px), + rgba(40, 0, 0, 0.72) calc(50% + 3px), + transparent calc(50% + 3px) + ), + radial-gradient(circle at 28% 22%, + rgba(255, 230, 230, 0.14) 0%, + rgba(255, 230, 230, 0.06) 16%, + transparent 36% + ), + radial-gradient(circle at 72% 80%, + rgba(20, 0, 0, 0.3) 0%, + rgba(20, 0, 0, 0.12) 16%, + transparent 34% + ); +} + +.automation_center_toggle, +.automation_outer_toggle { + position: absolute; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + box-sizing: border-box; + background: transparent; +} + +.automation_center_toggle { + inset: 0; + margin: auto; + width: 56px; + height: 56px; + border-radius: 50%; + font-size: 1.1em; + font-weight: 700; + border: 2px solid #000; + z-index: 4; + box-shadow: 0 0 0 3px #000; + flex-direction: column; + justify-content: flex-start; + padding-top: 9px; +} + +.automation_center_toggle.is-active { + background: linear-gradient(180deg, #f4fff6 0%, #cef3d6 100%); + border-color: #255f2d; + color: #255f2d; + box-shadow: + 0 0 0 3px #000, + 0 0 16px rgba(142, 232, 169, 0.28), + inset 0 2px 4px rgba(255, 255, 255, 0.55), + inset 0 -4px 6px rgba(44, 110, 47, 0.18); +} + +.automation_center_toggle.is-paused { + background: linear-gradient(180deg, #fff0f0 0%, #f0b2b2 100%); + border-color: #7a1414; + color: #6e0d0d; + box-shadow: + 0 0 0 3px #000, + 0 0 16px rgba(160, 0, 0, 0.22), + inset 0 2px 4px rgba(255, 255, 255, 0.28), + inset 0 -4px 6px rgba(120, 20, 20, 0.24); +} + +.automation_center_toggle_label { + position: relative; + z-index: 1; + display: block; + width: 100%; + text-align: center; + font-size: 9px; + line-height: 1; + letter-spacing: 0.12em; + font-weight: 800; + transform: translateY(-1px); +} + +.automation_center_toggle_icon_slot { + position: absolute; left: 0; + right: 0; + top: 10px; + bottom: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.automation_center_toggle_icon { + position: absolute; + left: 50%; + top: 50%; + line-height: 1; + font-size: 24px; + transform: translate(-50%, -50%); + transition: opacity 120ms ease, transform 120ms ease; + pointer-events: none; +} + +.automation_center_toggle_icon.is-play { + font-size: 26px; + transform: translate(-50%, -50%); +} + +.automation_center_toggle_icon.is-pause { + font-size: 20px; + letter-spacing: -0.08em; + transform: translate(calc(-50% - 2px), -50%); +} + +.automation_center_toggle_icon_current { + opacity: 1; +} + +.automation_center_toggle_icon_preview { + opacity: 0; +} + +.automation_center_toggle:hover .automation_center_toggle_icon_current, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_current { + opacity: 0; +} + +.automation_center_toggle:hover .automation_center_toggle_icon_current.is-play, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_current.is-play { + transform: translate(-50%, -50%) scale(0.9); +} + +.automation_center_toggle:hover .automation_center_toggle_icon_current.is-pause, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_current.is-pause { + transform: translate(calc(-50% - 2px), -50%) scale(0.9); +} + +.automation_center_toggle:hover .automation_center_toggle_icon_preview, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_preview { + opacity: 1; +} + +.automation_center_toggle:hover .automation_center_toggle_icon_preview.is-play, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_preview.is-play { + transform: translate(-50%, -50%) scale(1); +} + +.automation_center_toggle:hover .automation_center_toggle_icon_preview.is-pause, +.automation_center_toggle:focus-visible .automation_center_toggle_icon_preview.is-pause { + transform: translate(calc(-50% - 2px), -50%) scale(1); +} + +.automation_outer_toggle { + inset: 0; + z-index: 1; + clip-path: none; +} + +.automation_outer_toggle.is-reserved { + cursor: default; + color: #d9d9d9; +} + +.automation_outer_toggle:disabled { + pointer-events: none; +} + +.automation_outer_toggle.is-active { + color: #00c000; +} + +.automation_outer_toggle.is-paused { + color: #101010; +} + +.automation_outer_toggle.is-reserved { + color: #8e948d; +} + +.automation_outer_top::before { + content: none; +} + +.automation_outer_top { + clip-path: polygon(50% 50%, 0% 0%, 100% 0%); +} + +.automation_outer_right { + clip-path: polygon(50% 50%, 100% 0%, 100% 100%); +} + +.automation_outer_bottom { + clip-path: polygon(50% 50%, 100% 100%, 0% 100%); +} + +.automation_outer_left { + clip-path: polygon(50% 50%, 0% 100%, 0% 0%); +} + +.automation_outer_toggle_icon { + position: absolute; + width: 32px; + height: 32px; + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + filter: none; + opacity: 0.9; + z-index: 3; + display: block; +} + +.automation_outer_toggle.is-active .automation_outer_toggle_icon { + filter: none; +} + +.automation_outer_toggle.is-reserved .automation_outer_toggle_icon { + filter: grayscale(0.2); + opacity: 0.75; +} + +.automation_icon_preparation { + background-image: url(../icons/preparation.svg); + left: calc(50% - 16px); + top: 15%; + transform: translateY(-50%); +} + +.automation_icon_rotation_disabled, +.automation_icon_rotation_umpire, +.automation_icon_rotation_dual { + left: calc(50% - 16px); + top: 15%; + transform: translateY(-50%); + background-size: contain; + background-position: center center; + background-repeat: no-repeat; +} + +.automation_icon_rotation_disabled { + background-image: url(../icons/no_umpire.svg); + filter: grayscale(1); + opacity: 0.5; +} + +.automation_icon_rotation_umpire { + background-image: url(../icons/umpire.svg); +} + +.automation_icon_rotation_dual { + background-image: none; +} + +.automation_icon_rotation_dual::before, +.automation_icon_rotation_dual::after { + content: ''; + position: absolute; + top: 50%; + width: 28px; + height: 28px; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + transform: translateY(-50%); +} + +.automation_icon_rotation_dual::before { + left: -18px; + background-image: url(../icons/umpire.svg); +} + +.automation_icon_rotation_dual::after { + right: -18px; + background-image: url(../icons/service_judge.svg); +} + +.automation_icon_rotation_dual { + position: absolute; + display: block; + overflow: visible; +} + +.automation_icon_rotation_dual_swap { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 18px; + line-height: 1; + color: #444; + font-weight: 700; + opacity: 0.95; + pointer-events: none; + display: block; + z-index: 4; +} + +.automation_icon_rotation_dual > * { + display: block; +} + +.automation_icon_preparation_enabled, +.automation_icon_preparation_disabled { + background-image: url(../icons/preparation.svg); + right: 12%; + top: 50%; + transform: translate(50%, -50%); +} + +.automation_icon_preparation_disabled { + filter: grayscale(1); + opacity: 0.5; +} + +.automation_icon_oncourt_enabled, +.automation_icon_oncourt_disabled { + background-image: url(../icons/manual_call.svg); + left: calc(50% - 20px); + bottom: 15%; + transform: translateY(50%); +} + +.automation_icon_oncourt_disabled { + filter: grayscale(1); + opacity: 0.5; +} + +.automation_icon_tablet_enabled, +.automation_icon_tablet_disabled { + background-image: url(../icons/tablet.svg); + left: 12%; + top: 50%; + transform: translate(-50%, -50%); +} + +.automation_icon_tablet_disabled { + filter: grayscale(1); + opacity: 0.5; +} + +.metadata_right_container_2 > table { + margin-top: -5px; +} + +.errors_scroll_left { + direction: rtl; /* Scrollbar links */ + overflow-y: auto; + margin-top: 5px; +} + +.errors { + direction: ltr; /* Inhalt wieder normal */ color: #f00; - background: rgba(255, 255, 255, 0.8); white-space: pre-wrap; + max-height: 92px; } .vlink, @@ -86,8 +588,8 @@ a:hover { .tournament_btp_fetch, .tournament_ticker_push, .tournament_settings_link { - float: right; - margin-right: 0.5em; + display: inline-block; + color: black; } .tournament_settings fieldset { @@ -95,14 +597,200 @@ a:hover { padding: 0; border: none; } + +.tournament_settings .automation_group_box { + margin: 1em 0; + padding: 0.9em 1em 0.3em; + border: 1px solid #9f9f9f; + border-radius: 10px; + background: rgba(255, 255, 255, 0.35); + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.tournament_settings .automation_group_box.automation_group_box_content_disabled > *:not(legend) { + opacity: 0.58; +} + +.tournament_settings .automation_group_box > legend { + display: flex; + align-items: center; + gap: 0.5em; + padding: 0 0.35em; + font-weight: 700; +} + +.tournament_settings .automation_rule_box { + margin: 0.75em 0; + padding: 0.75em 0.9em; + border: 1px solid #bbb; + border-radius: 8px; + background: rgba(255, 255, 255, 0.45); + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.tournament_settings .automation_rule_box.automation_rule_box_disabled { + opacity: 0.58; +} + +.tournament_settings .automation_rule_box.automation_rule_box_value_disabled > label.automation_rule_value { + opacity: 0.58; +} + +.tournament_settings .automation_rule_box > legend { + display: flex; + align-items: center; + gap: 0.5em; + padding: 0 0.3em; + font-weight: 600; +} + +.tournament_settings .automation_rule_box > label.automation_rule_value > span { + width: 260px; + min-width: 180px; +} + +.tournament_settings .automation_rule_box > label:not(.automation_rule_value) > span { + width: auto; + min-width: 0; + flex: 1 1 auto; +} + +.tournament_settings .automation_rule_unit { + margin-left: 0.6em; + color: #444; + font-size: 0.95em; +} + +.tournament_settings .automation_rule_box > label { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; +} + +.tournament_settings .automation_rule_box > label > input[type=number] { + flex: 0 1 140px; + min-width: 0; +} + +.tournament_settings label.automation_suboption_checkbox { + margin-top: 0.8em; + padding-left: 0; +} + +.tournament_settings label.automation_suboption_checkbox > input[type=checkbox] { + margin-left: 1.43em; +} + +.tournament_settings label.automation_suboption_checkbox > span { + font-weight: 600; + margin-left: 0.56em; +} + +.tournament_settings label.automation_suboption_checkbox.automation_suboption_checkbox_disabled { + opacity: 0.58; +} + +.tournament_settings label.automation_suboption_checkbox_disabled { + opacity: 0.58; +} + +.tournament_settings .automation_suboption_hint { + margin: 0.15em 0 0 3.15em; + color: #666; + font-size: 0.9em; + display: none; +} + .tournament_settings > label, -.tournament_settings fieldset > label { - display: block; +.tournament_settings fieldset > label , +.tournament_settings > .settings > label, +.tournament_settings fieldset > .settings > label { + display: flex; + align-items: center; + margin: 4px 0; } + .tournament_settings > label > span, -.tournament_settings fieldset > label > span { +.tournament_settings fieldset > label > span, +.tournament_settings > .settings > label > span, +.tournament_settings fieldset > .settings > label > span { + display: flex; + align-items: center; + width: 400px; + min-width: fit-content; +} + +.tournament_settings > label > input[type=text], +.tournament_settings fieldset > label > input[type=text], +.tournament_settings > .settings > label > input[type=text], +.tournament_settings fieldset > .settings > label > input[type=text], +.tournament_settings > label > input[type=number], +.tournament_settings fieldset > label > input[type=number], +.tournament_settings > .settings > label > input[type=number], +.tournament_settings fieldset > .settings > label > input[type=number] { + flex-grow: 1; +} + +.tournament_settings > label > select, +.tournament_settings fieldset > label > select, +.tournament_settings > .settings > label > select, +.tournament_settings fieldset > .settings > label > select { + flex-grow: 1; +} + +.official_rotation_mode_control { + display: flex; + align-items: center; +} + +.official_rotation_mode_control > span { + display: inline-block; + width: 400px; + min-width: fit-content; +} + +.official_rotation_mode_control > select { + flex-grow: 1; +} + + + +.tournament_settings { + display: block; + columns: 920px 6; +} + +.settings { display: inline-block; - min-width: 5em; + background-color: #ddd; + width: 900px; + padding: 10px; + border-radius: 10px; + margin: 10px; +} + +.live_settings_status { + margin: 0.5em 0 1em 0; + font-size: 0.9em; + font-weight: bold; +} + +.live_settings_status_saving { + color: #8a5a00; +} + +.live_settings_status_saved { + color: #2c6e2f; +} + +.live_settings_status_error { + color: #9b1c1c; } .dialog_bg { @@ -127,21 +815,13 @@ a:hover { background: #eee; } -.footer_links { - margin-top: 1em; - margin-bottom: 0.5em; -} -.footer_links > * { - display: inline-block; -} -.footer_links > * + * { - margin-left: 1em; -} - table.striped-table { } +.old { + position: fixed; left: 0; right: 0; top: 0; bottom: 0; +} .main_upcoming { background: #000; color: #fff; @@ -150,15 +830,798 @@ table.striped-table { font-size: 35px; cursor: none; } + +.end_container { + height: 400px; +} + +.help_me{ + position: fixed; + left: 0; right: 0; top: 0; bottom: 0; + height: 975px; + background-color: red; + width: 50px; +} + .upcoming_table { border-collapse: collapse; + width: 98%; + margin: 0 1% 0 1%; } .upcoming_table td { - padding-bottom: 0.3em; - vertical-align: top; + vertical-align: middle; } .upcoming_table td:not(:first-child) { padding-left: 0.3em; + padding-right: 0.3em; white-space: pre-wrap; } + +.metadata_container { + background-color: #00000020; + display: flex; + flex-direction: row; + flex-wrap: wrap-reverse; + justify-content: space-around; + padding: 0.25em 0 0 0; + margin: 0 0 0.75em 0; + border-bottom: 1px solid black; +} + +.announcements_container{ + width: 450px; +} +.announcements_container > form { + display: flex; + flex-direction: column; + justify-content: space-evenly; +} + +.announcement_speech_check_form { + gap: 0.4em; + margin-top: 0.5em; +} + +.announcement_speech_check_status { + display: flex; + flex-wrap: wrap; + gap: 0.35em; + align-items: baseline; +} + +.announcement_speech_check_label { + font-weight: 600; +} + +.announcement_speech_check_value_ok { + color: #0f6b1a; +} + +.announcement_speech_check_value_error, +.announcement_speech_check_value_timeout, +.announcement_speech_check_value_suspicious { + color: #a11b1b; +} + +.announcement_speech_check_value_running { + color: #8a5a00; +} + +.announcement_speech_check_value_unsupported { + color: #555; +} + +.status_error, +.status_waiting, +.status_deactivated, +.status_connecting, +.status_connected { + display: inline-block; + background-size: 100%; + background-repeat: no-repeat; + background-position: center center; + height: 1.0em; + min-height: 1em; + width: 1.0em; + margin: 0 0.2em -0.2em 0.2em; +} + +.status_connected { + background-image: url(../icons/signal_green.svg); +} + +.status_connecting { + background-image: url(../icons/signal_yellow.svg); +} + +.status_waiting, +.status_error { + background-image: url(../icons/signal_red.svg); +} + +.status_deactivated { + background-image: url(../icons/signal_black.svg); +} + + +h2 { + margin: 0; + padding-top: 15px; + padding-bottom: 5px; + font-size: 70px; + color: #fff; + text-align: center; +} + +h2.edit { + margin: 10px 0 20px 0; + padding-top: 0px; + padding-bottom: 0px; + font-size: 25px; + color: black; + text-align: left; +} + +.settings table > tbody > tr { + background-color: #bbb; +} + +.settings table > tbody > tr:nth-of-type(even) { + background-color: #ccc; +} + +.settings .online { + background-color: #97e6b4; +} + +.settings table > tbody > .online:nth-of-type(even) { + background-color: #adffcb; +} + +.settings .wait_for_done { + background-color: #dfe197; +} + +.settings table > tbody > .wait_for_done:nth-of-type(even) { + background-color: #fcffad; +} + + +.settings .offline { + background-color: #e69797 +} + +.settings table > tbody > .offline:nth-of-type(even) { + background-color: #ffadad; +} + +.settings table { + border:none; + border-spacing: 0; + width: 100%; +} + +.scoring_formats_table th, +.scoring_formats_table td { + padding: 0.35em 0.5em; + vertical-align: middle; +} + +.scoring_formats_table th { + text-align: left; +} + +.scoring_formats_table tr.scoring_formats_row_group_odd > td, +.scoring_formats_table tr.scoring_formats_row_group_odd > th { + background-color: #bbb; +} + +.scoring_formats_table tr.scoring_formats_row_group_even > td, +.scoring_formats_table tr.scoring_formats_row_group_even > th { + background-color: #ccc; +} + +.scoring_formats_table .scoring_formats_subrow td { + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +.scoring_format_type_cell { + font-weight: 700; + white-space: nowrap; + text-align: left; +} + +.scoring_format_rule_cell { + white-space: nowrap; +} + +.scoring_format_name_cell { + text-align: left; +} + +.scoring_format_center_cell { + text-align: center; +} + +.scoring_format_right_cell { + text-align: right; +} + +.default_scoring_format_badge { + display: inline-block; + min-width: 1.6em; + padding: 0.1em 0.35em; + border-radius: 999px; + background: #ffd54f; + color: #4a3500; + font-weight: 700; + text-align: center; + white-space: nowrap; + box-shadow: inset 0 0 0 1px rgba(74, 53, 0, 0.18); +} + +.default_scoring_format_badge_inactive { + background: transparent; + color: #666; + box-shadow: none; + font-weight: 400; +} + +.scoring_format_edit_container { + display: grid; + gap: 1rem; + min-width: 36rem; +} + +.scoring_format_edit_row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.scoring_format_edit_row > input { + width: 10rem; +} + +.scoring_format_edit_section { + border: 1px solid #bbb; + padding: 0.75rem 1rem 1rem 1rem; +} + +.scoring_format_edit_section > legend { + padding: 0 0.35rem; + font-weight: 700; +} + +.edit_display_setting_container{ + text-align: left; +} + +.edit_display_setting_container > div{ + margin: 10px; + display: flex; + flex-direction: row; + justify-content: space-between; +} +.edit_display_setting_container > div > input[type=text]{ + width: 400px; +} + +.edit_display_setting_container > div > input{ + width: 200px; +} + +.edit_display_setting_container > div > select{ + width: 300px; +} + + +#meetingpoint_announcement{ + width: -webkit-fill-available; + resize: vertical; + min-height: 5.0em; + height: 5.0em; +} + + +#preparation_addition{ + width: -webkit-fill-available;; + resize: vertical; + min-height: 5.0em; + height: 5.0em; +} + +.courts_table{ + text-align: center; +} + + +.icon_td { + text-align: center; +} + +.upload_filename_location{ + font-style: italic; + color: #555; + max-width: 150px; + inline-size: 150px; + overflow-wrap: break-word; +} + +.official_split_section, +.paralel { + display: grid; + grid-template-columns: minmax(0, 1fr) 10px minmax(0, 1fr); + gap: 0; + align-items: start; +} + +.official_role_split_column { + min-width: 0; +} + +.official_role_split_space { + background: #eee; + align-self: stretch; +} + +.space { + background: #eee; + align-self: stretch; +} + +.officials_list, +.officials_dual_list { + display: flex; + flex-direction: column; + gap: 1px; + background: #ddd; +} + +.officials_row, +.officials_dual_row { + background: #fff; +} + +.officials_row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 50px 50px; + align-items: stretch; +} + +.officials_row:not(.officials_list_header)[data-official-id] { + cursor: grab; +} + +.officials_row.dragging, +.official_assignment_slot.dragging { + cursor: grabbing; + opacity: 0.5; +} + +.officials_list_header, +.officials_dual_header { + background: #ccc; + font-weight: 600; +} + +.officials_cell, +.officials_dual_cell { + min-width: 0; + padding: 4px 6px; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.officials_cell_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.officials_cell_leading { + width: 38px; + min-width: 38px; + max-width: 38px; + justify-content: center; + padding-left: 0; + padding-right: 0; +} + +.officials_cell_role { + width: 50px; + min-width: 50px; + max-width: 50px; + justify-content: center; +} + +.officials_cell_role input { + margin: 0; +} + +.officials_dual_list { + gap: 1px; +} + +.officials_dual_row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 50px 50px 10px minmax(0, 1fr) 50px 50px; + align-items: stretch; +} + +.officials_dual_row_inactive > .officials_dual_cell { + background-color: #00000040; +} + +.officials_dual_center_space { + background: #eee; + padding: 0; +} + +.officials_table_on_court .officials_cell_name { + color: #666; +} + +.officials_table_on_court .officials_cell_leading, +.officials_table_on_court .officials_cell_role, +.officials_table_in_preparation .officials_cell_leading, +.officials_table_in_preparation .officials_cell_role { + color: inherit; +} + + +.officials_role_toggle { + display: flex; + justify-content: center; + width: 100%; +} + +.officials_drop_zone { + height: 12px; + background: inherit; +} + +.officials_list.drop-zones-active .officials_drop_zone, +.officials_dual_list.drop-zones-active .official_assignment_slot.drop-zone { + background: rgba(255, 215, 0, 0.35); +} + +.officials_list.drop-zones-active .officials_drop_zone.drop-zone-hover, +.officials_dual_list.drop-zones-active .official_assignment_slot.drop-zone.drop-zone-hover { + background: rgba(255, 215, 0, 0.65); +} + +.official_drag_image_table { + border-collapse: separate; + border-spacing: 0; +} + +.official_drag_image_cell { + box-sizing: border-box; +} + +.official_section_body { + display: flex; + gap: 10px; + align-items: stretch; + flex-wrap: wrap; + margin-bottom: 12px; + row-gap: 0; +} + +.official_on_court_row { + display: grid; + grid-template-columns: 65px minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + align-items: stretch; + width: 100%; + padding: 8px 0; +} + +.official_on_court_row.official_on_court_row_single_role { + grid-template-columns: 65px minmax(0, 1fr); +} + +.official_inactive_row { + grid-template-columns: 65px minmax(0, 1fr); +} + +.official_section_body > .official_on_court_row:nth-child(odd) { + background: #bbb; +} + +.official_section_body > .official_on_court_row:nth-child(even) { + background: #ccc; +} + +.official_section_body > .official_on_court_row.official_on_court_row_inactive:nth-child(odd) { + background: #808080; +} + +.official_section_body > .official_on_court_row.official_on_court_row_inactive:nth-child(even) { + background: #8f8f8f; +} + +.official_on_court_leading { + display: flex; + align-items: center; + justify-content: center; +} + +.official_preparation_leading { + font-weight: 700; +} + +.official_on_court_leading .court, +.official_on_court_leading .court_inactive { + margin: 0 0.2em -0.2em 0.2em; + width: 1.5em; + min-width: 1.5em; + height: 1em; + min-height: 1em; + font-size: 1.1em; + line-height: 100%; + background-size: cover; + transform: scale(1.5); + transform-origin: center; +} + +.official_on_court_slot { + min-height: 1px; +} + +.official_on_court_slot > .official_card_frame { + width: 100%; +} + +.official_card_frame { + width: 400px; + height: 38px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + border-radius: 8px; +} + +.official_card_skin { + background: #fff4b8; + border: 1px solid #d6c36a; + padding: 4px 9px 4px 4px; +} + +.official_card_skin.official_card_variant_on_court { + background: #dddddd; + border-color: #666666; + color: #666666; +} + +.official_card_skin.official_card_variant_checked_in { + background: #adffcb; + border-color: #6fbe8d; + color: #000000; +} + +.official_card_skin.official_card_variant_not_checked_in { + background: #ffabab; + border-color: #cf7f7f; + color: #000000; +} + +.official_card_skin.official_card_variant_list { + background: #dddddd; + border-color: #000000; + color: #000000; +} + +.official_card_skin.official_card_placeholder { + background: transparent; + border: 1px dashed #999; +} + +.official_card_skin.official_card_placeholder > * { + visibility: hidden; +} + +.official_card_skin.official_card_variant_on_court .official_card_icon, +.official_card_skin.official_card_variant_on_court .official_card_trail_icon { + filter: none; + opacity: 1; +} + +.official_card_skin.official_card_variant_on_court .official_card_trail_swap { + color: #000000; + opacity: 1; +} + +.official_card_frame.official_card_placeholder_compact { + height: 12px; + margin: 0; + transition: none; +} + +.official_card_frame.official_card_placeholder_hoverlike { + height: 38px; + margin: 14px 0; +} + +.official_card_skin.official_card_placeholder.official_card_placeholder_activelike { + background: #fff4b8; + border-color: #d6c36a; +} + +.official_card_skin.official_card_placeholder.official_card_placeholder_hoverlike { + background: #ffe97a; + border-color: #c6a800; +} + +.official_card_drop { + border: 1px dashed transparent; + background: transparent; + transition: background-color 90ms linear, border-color 90ms linear, height 110ms ease-out, margin 110ms ease-out; +} + +.officials_drag_active .official_card_drop { + border-color: #999; +} + +.official_card_drop.official_card_drop_disabled { + border-color: transparent; +} + +.official_card_drop.official_card_drop_active { + background: #fff4b8; + border-color: #d6c36a; +} + +.official_card_drop.official_card_drop_hover { + background: #ffe97a; + border-color: #c6a800; +} + +.official_card_stack > .official_card_drop { + height: 12px; + margin: 0; +} + +.official_card_stack > .official_card_drop.official_card_drop_terminal { + height: 38px; + margin: 14px 0; +} + +.official_card_stack > .official_card_drop.official_card_drop_active { + height: 12px; +} + +.official_card_stack > .official_card_drop.official_card_drop_terminal.official_card_drop_active, +.official_card_stack > .official_card_drop.official_card_drop_terminal.official_card_drop_hover, +.official_card_stack > .official_card_drop.official_card_drop_terminal.official_card_drop_hover.official_card_drop_hover_expand { + height: 38px; + margin: 14px 0; +} + +.official_card_stack.official_card_stack_has_nonterminal_hover > .official_card_drop.official_card_drop_terminal, +.official_card_stack.official_card_stack_has_nonterminal_hover > .official_card_drop.official_card_drop_terminal.official_card_drop_active, +.official_card_stack.official_card_stack_has_nonterminal_hover > .official_card_drop.official_card_drop_terminal.official_card_drop_hover, +.official_card_stack.official_card_stack_has_nonterminal_hover > .official_card_drop.official_card_drop_terminal.official_card_drop_hover.official_card_drop_hover_expand { + height: 12px; + margin: 0; +} + +.official_card_stack > .official_card_drop.official_card_drop_suppressed { + display: none; + background: transparent; + border-color: transparent; + pointer-events: none; +} + +.official_card_stack > .official_card_drop.official_card_drop_hover { + height: 12px; +} + +.official_card_stack > .official_card_drop.official_card_drop_hover.official_card_drop_hover_expand { + height: 38px; + margin: 14px 0; +} + +.official_card_stack { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.official_card_frame .official_card_icon { + flex: 0 0 auto; + width: 1.75em; + min-width: 1.75em; + align-self: stretch; + height: auto; + min-height: 0; + margin: 0 !important; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; +} + +.official_card_name { + min-width: 0; + font-size: 1.2em; + display: flex; + align-items: center; + flex: 1 1 auto; +} + +.official_card_timer { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.official_card_timer .timer { + min-width: 2.6em; + text-align: center; + font-size: 0.9em; + padding: 0.2em 0.3em 0.0em 0.3em; + margin: -0.1em 0.2em 0em 0.2em; + font-size: 0.7em; + background-color: (255, 0, 0); + color: (255, 255, 255); + border-radius: 0.4em; + font-weight: normal; + text-align: right; + font-family: "SEGMENT"; +} + +.official_card_dragging { + opacity: 1; +} + +.official_card_trail { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0; + margin-left: auto; + cursor: pointer; +} + +.official_rotation_mode_umpire_only .official_card_trail { + display: none; +} + +.official_card_frame .official_card_trail_icon { + width: 1em; + min-width: 1em; + height: 1em; + min-height: 1em; + margin: 0 !important; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; +} + +.official_card_trail_swap { + display: flex; + align-items: center; + justify-content: center; + width: 0.7em; + min-width: 0.7em; + font-size: 0.75em; + line-height: 1; + color: #444; +} + +.official_card_trail[data-state="service_only"] .official_card_trail_icon.umpire, +.official_card_trail[data-state="umpire_only"] .official_card_trail_icon.service_judge { + filter: grayscale(1); + opacity: 0.45; +} + +.official_card_trail[data-state="service_only"] .official_card_trail_swap, +.official_card_trail[data-state="umpire_only"] .official_card_trail_swap { + visibility: hidden; +} diff --git a/static/css/cmatch.css b/static/css/cmatch.css index b3b2a5e..e33d451 100644 --- a/static/css/cmatch.css +++ b/static/css/cmatch.css @@ -1,22 +1,57 @@ .match_save_button { font-size: 120%; padding: 9px 20px; + margin: 10px 0px 10px 0px; +} + +.announce_button { + font-size: 100%; + padding: 9px 20px; + margin: 10px 0px 10px 0px; +} + +.announce_emergency_button { + font-size: 100%; + padding: 9px 20px; + margin: 10px 0px 10px 0px; + background-color: red; + color: whitesmoke; + font-weight: bold; +} + +.stop_emergency_button { + font-size: 100%; + padding: 9px 20px; + margin: 10px 0px 10px 0px; + background-color: green; + color: whitesmoke; + font-weight: bold; +} + +.announcements_btn_container { + display: flex; + justify-content: space-around; } .match_num { text-align: right; + padding: 0 0.3em 0 0.3em; } .match_edit_button, .match_scoresheet_button, +.match_preparation_call_button, +.match_begin_to_play_button, +.match_manual_call_button, +.match_second_call_button, +.match_second_preparation_call_button, .match_rawinfo { display: inline-block; - width: 1em; min-width: 1em; - height: 1em; min-height: 1em; background-size: 100%; - opacity: 0.5; + background-repeat: no-repeat; + background-position: center center; } .match_edit_button { background-image: url(../icons/edit.svg); @@ -25,11 +60,51 @@ margin: 0 0 0 0.3em; background-image: url(../icons/scoresheet.svg); } + +.match_preparation_call_button { + height: 100%; + width: auto; + margin: 0 0 -0.1em 0.3em; + /*background-image: url(../icons/preparation.svg);*/ +} +.match_manual_call_button { + height: 1.5em; + min-height: 1em; + width: 2.3em; + margin: 0 0 -0.1em 0.3em; + background-image: url(../icons/manual_call.svg); +} +.match_begin_to_play_button { + height: 1.5em; + min-height: 1em; + width: 2.3em; + margin: 0 0 -0.1em 0.3em; + background-image: url(../icons/start.svg); +} + +.match_second_call_button, +.match_second_preparation_call_button { + height: 1.2em; + min-height: 1em; + background-size: cover; + width: 1.9em; + margin: 0 0.2em -0.2em 0.2em; + background-image: url(../icons/second_call.svg); +} + .match_rawinfo { margin: 0 0.3em 0 0.3em; background-image: url(../icons/rawinfo.svg); } +.match_timer > .match_confirm_button { + min-height: 1.6em; + background-size: cover; + background-color: #00000000; + width: 2.3em; + background-image: url(../icons/confirm_match.svg); +} + .edit_match_container { text-align: left; } @@ -42,18 +117,68 @@ } .match_edit_button:hover, -.match_scoresheet_button:hover { - opacity: 1; +.match_second_call_button:hover, +.match_second_preparation_call_button:hover, +.match_preparation_call_button:hover, +.match_begin_to_play_button:hover, +.match_manual_call_button:hover, +.match_scoresheet_button:hover, +.match_confirm_button:hover { + opacity: 0.5; } .match_table { border-collapse: collapse; + width: 98%; + margin: 0 1% 0 1%; } + .match_table > thead > tr > th { text-align: left; } .match_table > tbody > tr:hover > td { - background: #ddd; + background-color: #00000030; +} + +.match_table > tbody > tr:nth-of-type(even) > td { + background-color: #00000010; +} + +.match_table > tbody > tr:nth-of-type(even):hover > td { + background-color: #00000030; +} + +.match_table > tbody > tr > td.inactive { + + background-color: #00000040; +} + +.match_table > tbody > tr:nth-of-type(even) > td.inactive { + + background-color: #00000050; +} + +.match_table > tbody > tr > td.droppable_active { + + background-color: #fbe44d44; +} + +.match_table > tbody > tr:hover > td.droppable_active { + background-color: #fbe44dbb; +} + +.match_table > tbody > tr:nth-of-type(even) > td.droppable_active { + + background-color: #fbe44d88; +} + +.match_table > tbody > tr:nth-of-type(even):hover > td.droppable_active { + background-color: #fbe44dbb; +} + + +.match_table > tbody > tr > #droppable { + min-width: 1000px; } .match_cancel_link { @@ -64,35 +189,131 @@ text-align: right; color: #666; } + .match_team2 { - padding-right: 1em; + padding-right: 0.3em; +} + +.match_team1_public, +.match_team2_public { + width: 50%; + text-wrap: nowrap; +} + +.match_team1_upcoming, +.match_team2_upcoming { + width:50%; + text-wrap: nowrap; + padding: 0px !important; + margin: 0px !important; } + +.match_team1_upcoming > span, +.match_team2_upcoming > span{ + text-wrap: nowrap; +} + .match_vs { color: #999; - padding: 0 0.5em; + width: 1.0em; + text-align: center; + margin: 0px; } .match_team_won { font-weight: bold; } + +.match_score{ + font-weight: bold; + padding-left: 0.2em; + text-wrap: nowrap; +} .match_score_current { font-weight: bold; + font-size: 1.5em; + padding-left: 0.3em; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; +} + +.no_status_public { + font-size: 60px; +} + +.operators_public { + font-size: 60px; + font-weight: 100; } + +.umpire_name_public { + font-size: 60px; + font-weight: 100; +} + +.score_public { + width: 550px !important; + max-width: 550px !important; +} + +.score_public > .match_score_current { + font-size: 1.82em; +} + + .match_score_current > .tablet, + .match_score_current > .service_judge, + .match_score_current > .umpire { + min-width: 1em; + width: 1em; + min-height: 0.65em; + height: 0.65em; + margin: 0px 5px; + /*cursor: none;*/ + } + +.match_score_current > .match_no_umpire, +.match_score_current > span { + padding: 0 0; + font-size: 35px; + font-weight: 300; + /*cursor: none;*/ +} + +.match_timer > .timer { + font-size: 1.2em; + font-weight: 900; + background-color: #111111; + min-width: 2.3em; + text-align: right; + padding: 0.1em 0.3em 0.1em 0.3em; + border-radius: 7px; +} + .match_shuttle_count { white-space: pre; text-align: right; - padding-left: 1em; } + +.match_shuttle_count_display_active { + width: 40px; +} + .match_shuttle_count span::after { content: ''; display: inline-block; - width: 1em; height: 1em; } -.match_shuttle_count span.match_shuttle_count_display_active::after { +.match_shuttle_image{ background-image: url("../../bup/icons/shuttle.svg"); - background-size: contain; - background-position: center; + height: 1.0em; + min-height: 1em; + width: 0.8em; + margin: 0.0em 0.0em -0.1em 0.0em; + background-size: 100%; background-repeat: no-repeat; + background-position: center center; } .match_no_umpire { @@ -110,7 +331,11 @@ max-height: 100vh; } - +match_preparation_call_button, +match_begin_to_play_button, +match_second_call_button, +match_second_preparation_call_button, +match_manual_call_button, .match_scoresheet_buttons { position: fixed; left: 0; @@ -132,10 +357,551 @@ min-width: 5em; } +.match_second_call_button > * , +.match_second_preparation_call_button > * { + display: block; +} + +.match_second_call_button > button, +.match_second_preparation_call_button > button { + margin: 0 0 0 2vmin; + font-size: 5vmin; + padding: 0.1em 0.1em; + min-width: 5em; +} + .match_umpire_style_umpires { font-weight: bold; } +.can_check_out { + cursor: url('../icons/check_out.png')15 15, auto; +} + +.can_check_in { + cursor: url('../icons/check_in.png')15 15, auto; +} + +.person_status_target { + display: inline-block; + padding: 0 0.15em; +} + +.checked_in > .person_status_target { + background-color: #adffcb; +} + +.not_checked_in > .person_status_target { + background-color: #ffabab; +} + +.player.checked_in { + background-color: #adffcb; +} + +.player.not_checked_in { + background-color: #ffabab; +} + +.person.can_check_in:not(.player), +.person.can_check_out:not(.player) { + cursor: default; +} + +.person.can_check_in:not(.player) > .person_status_target { + cursor: url('../icons/check_in.png')15 15, auto; +} + +.person.can_check_out:not(.player) > .person_status_target { + cursor: url('../icons/check_out.png')15 15, auto; +} + +.now_playing { + background-color: #fbe44d; +} + +.now_on_court { + background-color: unset; +} + +.main_self_check_in { + background: #000; + color: #fff; + height: 100vh; + overflow: hidden; + padding: 20px; + box-sizing: border-box; +} + +.self_check_in_container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +.self_check_in_called_overlay { + position: fixed; + inset: 20px; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.self_check_in_called_overlay_backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + pointer-events: auto; +} + +.self_check_in_called_overlay_host { + position: relative; + z-index: 1; + width: min(1100px, calc(100vw - 80px)); + height: min(72vh, 700px); + pointer-events: auto; +} + +.self_check_in_called_overlay_card { + height: 100%; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55); +} + +.self_check_in_title { + margin: 0 0 1rem; + text-align: center; + flex: 0 0 auto; + font-size: clamp(2rem, 4vw, 3.8rem); +} + +.self_check_in_empty { + margin: auto 0; + text-align: center; + font-size: clamp(1.4rem, 3vw, 2rem); + color: #ddd; +} + +.self_check_in_list { + flex: 1 1 auto; + display: grid; + gap: 0.9rem; + min-height: 0; + align-items: stretch; +} + +.self_check_in_match { + --self-check-in-header-height: 96px; + --self-check-in-header-gap: 8px; + --self-check-in-number-font-size: 22px; + --self-check-in-event-font-size: 34px; + --self-check-in-meta-font-size: 14px; + --self-check-in-status-font-size: 14px; + border-radius: 1rem; + padding: calc(1rem * var(--self-check-in-scale, 1)); + border: 3px solid #444; + background: #111; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, var(--self-check-in-header-height)) minmax(0, 1fr); + overflow: hidden; +} + +.self_check_in_match_ready { + border-color: #4caf50; + box-shadow: 0 0 0 1px rgba(76, 175, 80, 0.35); +} + +.self_check_in_match_waiting { + border-color: #9a6a00; + box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.25); +} + +.self_check_in_match_heading { + display: block; + margin-bottom: var(--self-check-in-header-gap); + min-height: 0; + overflow: hidden; +} + +.self_check_in_match_top_row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.self_check_in_match_heading_left { + min-width: 0; +} + +.self_check_in_match_number { + font-size: var(--self-check-in-number-font-size); + font-weight: bold; + line-height: 1; +} + +.self_check_in_match_event { + font-size: var(--self-check-in-event-font-size); + font-weight: bold; + line-height: 1; + min-width: 0; +} + +.self_check_in_match_event_row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.self_check_in_match_court { + flex: 0 0 auto; + color: #bbb; + font-size: var(--self-check-in-event-font-size); + line-height: 1.1; + text-align: right; + white-space: nowrap; +} + +.self_check_in_match_meta { + margin-top: 0.2rem; + color: #bbb; + font-size: var(--self-check-in-meta-font-size); + line-height: 1.1; + min-width: 0; +} + +.self_check_in_match_status { + flex: 0 0 auto; + font-weight: bold; + text-align: right; + font-size: var(--self-check-in-status-font-size); + line-height: 1.1; + white-space: nowrap; +} + +.self_check_in_chips { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: calc(0.45rem * var(--self-check-in-scale, 1)); + align-content: stretch; + min-height: 0; + align-self: stretch; + height: 100%; + --self-check-in-chip-name-scale: 1; + --self-check-in-chip-box-scale: 1; + --self-check-in-chip-name-scale-row: 1; + --self-check-in-chip-box-scale-row: 1; +} + +.self_check_in_chips[data-columns="1"] { + grid-template-columns: minmax(0, 1fr); +} + +.self_check_in_chips[data-has-officials="1"][data-rows="2"] { + grid-template-rows: 0.9fr 1.1fr; +} + +.self_check_in_chips[data-has-officials="1"][data-rows="3"] { + grid-template-rows: 0.9fr 0.9fr 1.2fr; +} + +.self_check_in_chip { + appearance: none; + border: 0; + border-radius: min(1.8rem, calc(0.9rem * var(--self-check-in-scale, 1) * var(--self-check-in-chip-box-scale, 1))); + padding: calc(0.2rem * var(--self-check-in-scale, 1) * var(--self-check-in-chip-box-scale, 1) * var(--self-check-in-chip-box-scale-row, 1)) calc(0.5rem * var(--self-check-in-scale, 1) * var(--self-check-in-chip-box-scale, 1) * var(--self-check-in-chip-box-scale-row, 1)); + font: inherit; + color: #111; + display: inline-flex; + align-items: center; + gap: calc(0.25rem * var(--self-check-in-scale, 1) * var(--self-check-in-chip-box-scale, 1) * var(--self-check-in-chip-box-scale-row, 1)); + cursor: pointer; + width: 100%; + max-width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; +} + +.self_check_in_chip_with_role { + flex-direction: column; + align-items: stretch; + justify-content: center; +} + +.self_check_in_chip_waiting { + background: #ff8a80; +} + +.self_check_in_chip_ready { + background: #a5d6a7; +} + +.self_check_in_chip_role { + font-size: calc(0.58rem * var(--self-check-in-scale, 1) * var(--self-check-in-chip-box-scale, 1) * var(--self-check-in-chip-box-scale-row, 1)); + font-weight: bold; + text-transform: uppercase; + opacity: 0.8; + display: block; + line-height: 1; +} + +.self_check_in_chip_name { + font-size: calc(clamp(0.95rem, calc(2vw * var(--self-check-in-scale, 1)), 1.8rem) * var(--self-check-in-chip-name-scale, 1) * var(--self-check-in-chip-name-scale-row, 1)); + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + display: block; + width: 100%; + line-height: 1.2; +} + +.self_check_in_container[data-match-count="1"] .self_check_in_title { + margin-bottom: 1.5rem; +} + +.self_check_in_container[data-match-count="1"] .self_check_in_list { + --self-check-in-scale: 1; +} + +.self_check_in_container[data-match-count="1"] .self_check_in_match { + --self-check-in-header-height: 120px; +} + +.self_check_in_container[data-match-count="1"] .self_check_in_match_event { + font-size: clamp(1.8rem, 4vw, 3rem); +} + +.self_check_in_container[data-match-count="1"] .self_check_in_chip_name { + font-size: clamp(1.4rem, 3vw, 2.4rem); +} + +@font-face { + font-family: "SEGMENT"; + src: url("./seg7.ttf"); + } + +.player > .timer { + display: inline-block; + vertical-align: middle; + font-size: 0.7em; + padding: 0.2em 0.3em 0.0em 0.3em; + margin: -0.3em 0.2em 0em 0.2em; + border-radius: 0.4em; + font-weight: normal; + text-align: right; + min-width: 2.9em; + font-family: "SEGMENT"; +} + +.tablet, +.tablet_inline, +.umpire, +.service_judge, +.no_umpire, +.no_service_judge, +.court, +.court_history, +.court_upcoming { + margin: 0 0.2em -0.2em 0.2em; + display: inline-block; + width: 1.73em; + min-width: 1.73em; + height: 1.2em; + min-height: 1.2em; + background-size: cover; + opacity: 1; + color: #fff; + text-align: center; + -webkit-text-stroke-width: 1px; + -webkit-text-stroke-color: black; + text-shadow: 3px 3px 3px #000, -1px -1px 3px #000, 1px -1px 3px #000, -1px 1px 3px #000, 1px 1px 3px #000; +} + +.tablet, +.tablet_inline { + background-image: url(../icons/tablet.svg); +} + +.tablet { + margin: -1px 0.2em -0.2em 0.2em; +} + +.tablet_inline { + height: 1em; + min-height: 1em; + width: 1.4em; + min-width: 1.4em; + font-weight: 900; + font-size: 1.1em; + margin: -0.1em 0.1em 0.1em 0.1em; + line-height: 100%; +} + +.umpire, +.service_judge, +.no_umpire, +.no_service_judge, +.match_no_umpire { + text-wrap: nowrap; +} + +.tablet_operator { + display: flex; + flex-wrap: nowrap; + align-items: center; +} + +.umpire { + background-image: url(../icons/umpire.svg); +} + +.service_judge { + background-image: url(../icons/service_judge.svg); +} + +.no_umpire { + background-image: url(../icons/no_umpire.svg); +} + +.no_umpire_add { + position: relative; + filter: grayscale(0); + cursor: pointer; +} + +.no_umpire_add:hover { + filter: grayscale(0); + background-image: url(../icons/umpire.svg); +} + +.umpire.can_add_service_judge { + position: relative; + overflow: visible; + cursor: pointer; +} + +.umpire.can_add_service_judge::before { + content: ''; + position: absolute; + left: 0.45em; + top: 0.18em; + width: 1.45em; + height: 1em; + background-image: url(../icons/service_judge.svg); + background-size: contain; + background-repeat: no-repeat; + background-position: center center; + opacity: 0; + pointer-events: none; +} + +.umpire.can_add_service_judge:hover::before { + opacity: 1; +} + +.no_umpire_add::after, +.umpire.can_add_service_judge::after { + content: '+'; + position: absolute; + right: -0.18em; + bottom: -0.45em; + font-size: 2.3em; + line-height: 1; + font-weight: 900; + color: #1fb14e; + text-shadow: none; + -webkit-text-stroke-width: 0; + opacity: 0; + transition: opacity 120ms ease-out; + pointer-events: none; +} + +.no_umpire_add:hover::after, +.umpire.can_add_service_judge:hover::after { + opacity: 1; +} + +.court{ + background-image: url(../icons/court.svg); + height: 1em; + min-height: 1em; + width: 1.5em; + min-width: 1.5em; + font-weight: 900; + font-size: 1.1em; + margin: -0.1em 0.1em 0.1em 0.1em; + line-height: 100%; +} + + +.court_history, +.court_upcoming { + background-image: url(../icons/court_history.svg); + height: 1em; + min-height: 1em; + width: 1.5em; + min-width: 1.5em; + font-weight: 900; + font-size: 1.3em; + margin: -0.1em 0.3em 0.1em 0.3em; + line-height: 100%; +} + +.court_upcoming { + margin-left: 0; + margin-right: 0; +} + +.highlight_0 { + background-color: #ffffff; +} + +.highlight_1 { + background-color: #ffd838; +} + +.highlight_2 { + background-color: #ff79ab; +} + +.highlight_3 { + background-color: #fbad4b; +} + +.highlight_4 { + background-color: #1dace6; +} + +.highlight_5 { + background-color: #5ce1b8; +} + +.highlight_6 { + background-color: #c56bff; +} + +.preparation_container { + display: flex; + flex-direction: column; +} + +.preparation { + font-weight: bold; + text-wrap: nowrap; +} + +th > select { + width: 70px; +} @media print { .toprow, @@ -148,3 +914,113 @@ display: none; } } + +.incomplete { + color: #aaaaaa; + font-style: italic; +} + +td.actions { + width: 80px; +} + +td.court_number { + width: 60px; +} + +td.match_num { + width: 35px; + padding-right: 15px; +} + +td.match_properties { + width: 280px; +} + +.finished_container > table > tbody > tr > td.score { + width: 170px; +} + +.courts_container > table > tbody > tr > td.score { + width: 270px; +} + + + +td.umpire_and_tablet{ + width: 50px; + padding-right: 10px; +} + +td.match_timer { + width: 3.6em; +} + +td.call_td { + width: 5.5em; +} + +.name { + text-wrap: nowrap; +} + +.person { + text-wrap: nowrap; +} + +th.match_num { + text-align: center !important; +} + +th.players { + text-align: center !important; +} + +h3.section { + margin-left: 1%; + margin-right: 1%; + margin-top: 20px; + text-align: center; +} + +.main_upcoming > div > table > tbody > .court_row:nth-of-type(even), +.main_upcoming > div > table > tbody > tr:nth-of-type(even){ + background-color: #222222; +} + +.do_not_show { + display: none; +} + +.location { + color: #00000066; +} + +.match_number_upcoming, +.match_scheduled_upcoming, +.match_event_upcoming { + color: #aaa; + text-wrap: nowrap; + width: 50px; +} + +.court_number_upcoming { + width: 0px; +} + +.upcoming_tbody{ + vertical-align: middle; +} + +.upcoming_tbody > tr { + height: 65px; +} + +.upcoming_tbody > tr > td { + height: 100%; + vertical-align: inherit; +} + +.complete { + cursor: grab; +} diff --git a/static/css/court.css b/static/css/court.css index de62139..2e8303f 100644 --- a/static/css/court.css +++ b/static/css/court.css @@ -1,3 +1,57 @@ +.court_num, +.court_inactive { + display: inline-block; + background-size: cover; + opacity: 1; + color: #fff; + text-align: center; + height: 1.0em; + min-height: 1.0em; + width: 1.50em; + min-width: 1.50em; + font-weight: 900; + font-size: 1.8em; + -webkit-text-stroke-width: 1px; + -webkit-text-stroke-color: black; + text-shadow: + 3px 3px 3px #000, + -1px -1px 3px #000, + 1px -1px 3px #000, + -1px 1px 3px #000, + 1px 1px 3px #000; + line-height: 100%; + cursor: pointer; +} + .court_num { - vertical-align: top; + background-image: url(../icons/court.svg); + margin: -0.0em 0.0em 0.05em 0.0em; +} + +.court_inactive { + background-image: url(../icons/court_inactive.svg); + margin: -0.0em 0.0em -3.2px 0.0em; +} + +.court_num:hover, +.court_inactive:hover { + opacity: 0.5; +} + +.main_upcoming > div > .match_table > tbody > .court_row > .court_number > .court_num , +.main_upcoming > div > .match_table > tbody > .court_row > .court_num { + line-height: 100%; + padding: 0.1em 0.2em 0em 0.2em; + width: 80px; + min-width: 80px; + cursor: none; +} + +.main_upcoming > div > .match_table > tbody > .court_row > .court_number > .court_inactive , +.main_upcoming > div > .match_table > tbody > .court_row > .court_inactive { + line-height: 100%; + padding: 0.1em 0.2em 0em 0.2em; + width: 80px; + min-width: 80px; + cursor: none; } diff --git a/static/css/ctabletoperator.css b/static/css/ctabletoperator.css new file mode 100644 index 0000000..52594bd --- /dev/null +++ b/static/css/ctabletoperator.css @@ -0,0 +1,104 @@ +.tabletoperator_add_custom_button { + display: inline-block; + background-size: 100%; + background-repeat: no-repeat; + background-position: center center; + height: 25px; + background-size: cover; + width: 25px; + background-image: url(../icons/tabletoperator_add.svg); + padding: 0.4em 0.4em; + margin: 0.4em 0 0 0.25em; +} +.tabletoperator_add_button { + display: inline-block; + min-width: 1em; + min-height: 1em; + background-size: 100%; + background-repeat: no-repeat; + background-position: center center; + height: 1.2em; + background-size: cover; + width: 1.0em; + margin: 0 0.2em 0 0.2em; + background-image: url(../icons/tabletoperator_add.svg); +} +.tabletoperator_move_up_button { + display: inline-block; + min-width: 1em; + min-height: 1em; + background-size: 50%; + background-repeat: no-repeat; + background-position: center center; + height: 1.2em; + background-size: cover; + width: 0.8em; + margin: 0 -0.1em -0.2em 0.0em; + background-image: url(../icons/move_up.svg); +} +.tabletoperator_move_down_button { + display: inline-block; + min-width: 1em; + min-height: 1em; + background-size: 80%; + background-repeat: no-repeat; + background-position: center center; + height: 1.2em; + background-size: cover; + width: 0.8em; + margin: 0 0.0em -0.2em -0.1em; + background-image: url(../icons/move_down.svg); +} + +.tabletoperator_remove_button { + display: inline-block; + min-width: 1em; + min-height: 1em; + background-size: 100%; + background-repeat: no-repeat; + background-position: center center; + height: 1.2em; + background-size: cover; + width: 1.1em; + margin: 0 0.2em -0.2em 0.2em; + background-image: url(../icons/tabletoperator_remove.svg); +} + +.tabletoperator_add_custom_button:hover, +.tabletoperator_move_up_button:hover, +.tabletoperator_move_down_button:hover, +.tabletoperator_remove_button:hover, +.tabletoperator_add_button:hover { + opacity: 0.5; +} +.tabletoperator_add_custom_button > *, +.tabletoperator_remove_button > *, +.tabletoperator_add_button > * { + display: block; +} +.tabletoperator_add_custom_button > button, +.tabletoperator_remove_button > button, +.tabletoperator_add_button > button { + margin: 0 0 0 2vmin; + font-size: 5vmin; + padding: 0.1em 0.1em; + min-width: 5em; +} + +.unassigned_tableoperators_content { + height: 130px; + max-height: 130px; +} + +.tabletoperator_add_custom_input { + width: 290px; +} + +.tabletoperators_table { + border-collapse: collapse; + border: 0px solid; +} + +.tabletoperators_table > tbody > tr:nth-of-type(even) { + background-color: #ccc; +} diff --git a/static/css/cumpires.css b/static/css/cumpires.css index 46188fe..1dc905f 100644 --- a/static/css/cumpires.css +++ b/static/css/cumpires.css @@ -1,3 +1,9 @@ .umpires_since { color: #666; } + +.umpire_container_content { + height: 160px; + width: 325px; + overflow: auto; +} diff --git a/static/css/seg7.ttf b/static/css/seg7.ttf new file mode 100644 index 0000000..85c7b68 Binary files /dev/null and b/static/css/seg7.ttf differ diff --git a/static/css/seven-segment.ttf b/static/css/seven-segment.ttf new file mode 100644 index 0000000..214053f Binary files /dev/null and b/static/css/seven-segment.ttf differ diff --git a/static/css/toprow.css b/static/css/toprow.css index 063ef0a..184d570 100644 --- a/static/css/toprow.css +++ b/static/css/toprow.css @@ -1,21 +1,239 @@ .toprow { - border-bottom: 1px solid #ddd; padding: 0.3em 0 0.3em 1em; - margin-bottom: 1em; + background-color: rgba(245, 245, 245, 0.7); + border-bottom: 1px solid black; + display: flex; + position: sticky; + top: 0; + z-index: 30; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .toprow > .toprow_link, .toprow_right > .toprow_link { display: inline-block; + color: black; + font-weight: bold; +} + +.toprow > .toprow_link { + align-self: center; } .toprow_sep { margin: 0 0.5em; + align-self: center; } .toprow_right { - float: right; margin-right: 0.5em; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + flex: 1; } .toprow_right > * + * { margin-left: 1.2em; } + +.toprow_right > .status_label + .status_badge, +.toprow_right > .btp_status_label + .btp_status_badge, +.toprow_right > .ticker_status_label + .ticker_status_badge, +.toprow_right > .speech_output_status_label + .speech_output_status_badge { + margin-left: 0.3em; +} + +.toprow_service_badge { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 8.9em; + height: 2.1em; + font-size: 0.72em; + font-variant-numeric: tabular-nums; + font-weight: 700; + font-family: inherit; + line-height: 1; + color: #2c6e2f; + align-self: center; + padding: 0 0.55em; + text-align: center; + letter-spacing: 0.03em; + box-sizing: border-box; + vertical-align: middle; + --toprow-service-badge-bg: #adffcb; + --toprow-service-badge-border: #2c6e2f; + z-index: 0; +} + +.toprow_service_badge.status_connected, +.toprow_service_badge.status_connecting, +.toprow_service_badge.status_error, +.toprow_service_badge.status_waiting, +.toprow_service_badge.status_deactivated { + background-image: none; + background-size: initial; + background-repeat: initial; + background-position: initial; + min-height: 0; + margin: 0; +} + +.toprow_service_badge::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + border: 1px solid var(--toprow-service-badge-border); + border-radius: 0.45em; + background: var(--toprow-service-badge-bg); + z-index: -1; +} + +.toprow_service_badge.status_connected { + --toprow-service-badge-bg: #adffcb; + --toprow-service-badge-border: #2c6e2f; + color: #2c6e2f; +} + +.toprow_service_badge.status_connecting { + --toprow-service-badge-bg: #f2c200; + --toprow-service-badge-border: #6f5600; + color: #6f5600; +} + +.toprow_service_badge.status_error { + --toprow-service-badge-bg: #d40000; + --toprow-service-badge-border: #7a0000; + color: #ffffff; +} + +.toprow_service_badge.status_deactivated, +.toprow_service_badge.status_waiting { + --toprow-service-badge-bg: rgba(25, 25, 25, 0.78); + --toprow-service-badge-border: #000000; + color: #f7f7f7; +} + +.btp_status_badge.is-fetching { + --toprow-service-badge-bg: #adffcb; + --toprow-service-badge-border: #2c6e2f; + color: #2c6e2f; + font-family: inherit; + font-weight: 700; + justify-content: center; + text-align: center; +} + +.btp_status_badge.is-countdown { + justify-content: center; + text-align: center; +} + +.toprow_service_badge_prefix { + display: inline-block; + margin-right: 0.38em; + font-family: inherit; +} + +.toprow_service_badge_timer { + display: inline-block; + font-family: "SEGMENT"; + font-weight: normal; + font-size: calc(100% + 1px); + min-width: 2.6em; + text-align: right; + position: relative; + top: 2px; +} + +.toprow_menu { + position: relative; + display: inline-block; + padding-bottom: 0.35em; + margin-bottom: -0.35em; +} + +.toprow_menu_label { + display: inline-block; +} + +.toprow_menu_button { + font-size: 1.3em; + line-height: 1; +} + +.toprow_menu_dropdown { + position: absolute; + right: 0; + top: 100%; + display: none; + min-width: 264px; + width: max-content; + background: rgba(245, 245, 245, 0.7); + border: 1px solid #bbb; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); + padding: 0.35em 0; + z-index: 20; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.toprow_menu:hover::after, +.toprow_menu:focus-within::after { + content: ''; + position: absolute; + right: 0; + top: 100%; + width: 240px; + height: 1.4em; + transform: translateY(-1.4em); + z-index: 19; +} + +.toprow_menu:hover > .toprow_menu_dropdown, +.toprow_menu:focus-within > .toprow_menu_dropdown { + display: block; +} + +.toprow_menu_item { + display: block; + padding: 0.45em 1.1em; + color: black; + white-space: nowrap; + font-weight: normal; +} + +.toprow_menu_item::after { + content: attr(data-label); + display: block; + height: 0; + overflow: hidden; + visibility: hidden; + font-weight: bold; + pointer-events: none; +} + +.toprow_menu_item:link, +.toprow_menu_item:visited, +.toprow_menu_item:hover, +.toprow_menu_item:active { + color: black; +} + +.toprow_menu_separator { + height: 0; + margin: 0; + border-top: 1px solid #000; +} + +.toprow_menu_item:hover { + background: rgba(225, 225, 225, 0.7); + text-decoration: none; + font-weight: bold; +} diff --git a/static/icons/charging.svg b/static/icons/charging.svg new file mode 100644 index 0000000..15ced64 --- /dev/null +++ b/static/icons/charging.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/icons/check_in.png b/static/icons/check_in.png new file mode 100644 index 0000000..d8eba65 Binary files /dev/null and b/static/icons/check_in.png differ diff --git a/static/icons/check_in.svg b/static/icons/check_in.svg new file mode 100644 index 0000000..5e734a9 --- /dev/null +++ b/static/icons/check_in.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/icons/check_out.png b/static/icons/check_out.png new file mode 100644 index 0000000..a607f3c Binary files /dev/null and b/static/icons/check_out.png differ diff --git a/static/icons/check_out.svg b/static/icons/check_out.svg new file mode 100644 index 0000000..1fafd43 --- /dev/null +++ b/static/icons/check_out.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/icons/confirm_match.svg b/static/icons/confirm_match.svg new file mode 100644 index 0000000..8e2cb1f --- /dev/null +++ b/static/icons/confirm_match.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/static/icons/court.svg b/static/icons/court.svg new file mode 100644 index 0000000..9926afc --- /dev/null +++ b/static/icons/court.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/static/icons/court_history.svg b/static/icons/court_history.svg new file mode 100644 index 0000000..0258d87 --- /dev/null +++ b/static/icons/court_history.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/static/icons/court_inactive.svg b/static/icons/court_inactive.svg new file mode 100644 index 0000000..16f6f5c --- /dev/null +++ b/static/icons/court_inactive.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/inactive.svg b/static/icons/inactive.svg new file mode 100644 index 0000000..6c8598b --- /dev/null +++ b/static/icons/inactive.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/static/icons/manual_call.svg b/static/icons/manual_call.svg new file mode 100644 index 0000000..da559ed --- /dev/null +++ b/static/icons/manual_call.svg @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/move_down.svg b/static/icons/move_down.svg new file mode 100644 index 0000000..9d3024a --- /dev/null +++ b/static/icons/move_down.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/icons/move_up.svg b/static/icons/move_up.svg new file mode 100644 index 0000000..0b11a03 --- /dev/null +++ b/static/icons/move_up.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/icons/no_umpire.svg b/static/icons/no_umpire.svg new file mode 100644 index 0000000..f326720 --- /dev/null +++ b/static/icons/no_umpire.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/static/icons/path838.png b/static/icons/path838.png new file mode 100644 index 0000000..5023a9f Binary files /dev/null and b/static/icons/path838.png differ diff --git a/static/icons/preparation.svg b/static/icons/preparation.svg new file mode 100644 index 0000000..16a8428 --- /dev/null +++ b/static/icons/preparation.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/second_call.svg b/static/icons/second_call.svg new file mode 100644 index 0000000..b026d6f --- /dev/null +++ b/static/icons/second_call.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + 2 + + diff --git a/static/icons/service_judge.svg b/static/icons/service_judge.svg new file mode 100644 index 0000000..cc0f4bc --- /dev/null +++ b/static/icons/service_judge.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/static/icons/signal_black.svg b/static/icons/signal_black.svg new file mode 100644 index 0000000..efa6ff0 --- /dev/null +++ b/static/icons/signal_black.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/static/icons/signal_green.svg b/static/icons/signal_green.svg new file mode 100644 index 0000000..a3d6165 --- /dev/null +++ b/static/icons/signal_green.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/static/icons/signal_red.svg b/static/icons/signal_red.svg new file mode 100644 index 0000000..9dbed28 --- /dev/null +++ b/static/icons/signal_red.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/static/icons/signal_yellow.svg b/static/icons/signal_yellow.svg new file mode 100644 index 0000000..d80e71d --- /dev/null +++ b/static/icons/signal_yellow.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/static/icons/start.svg b/static/icons/start.svg new file mode 100644 index 0000000..5ee9a6e --- /dev/null +++ b/static/icons/start.svg @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/tablet.svg b/static/icons/tablet.svg new file mode 100644 index 0000000..9901ccd --- /dev/null +++ b/static/icons/tablet.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + +1 + + +1 + + diff --git a/static/icons/tabletoperator_add.svg b/static/icons/tabletoperator_add.svg new file mode 100644 index 0000000..7a3f9f2 --- /dev/null +++ b/static/icons/tabletoperator_add.svg @@ -0,0 +1,394 @@ + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + +1 + +1 + + + + + + + + +1 + +1 + + + + + + + + +1 + +1 + + + + + + + diff --git a/static/icons/tabletoperator_remove.svg b/static/icons/tabletoperator_remove.svg new file mode 100644 index 0000000..c10c042 --- /dev/null +++ b/static/icons/tabletoperator_remove.svg @@ -0,0 +1,472 @@ + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + +1 + +1 + + + + + + + + +1 + +1 + + + + + + + + +1 + +1 + + + + + + + + + + + + +1 + +1 + + + diff --git a/static/icons/umpire.svg b/static/icons/umpire.svg new file mode 100644 index 0000000..530d1c6 --- /dev/null +++ b/static/icons/umpire.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/static/js/announcements.js b/static/js/announcements.js new file mode 100644 index 0000000..7c83e18 --- /dev/null +++ b/static/js/announcements.js @@ -0,0 +1,1013 @@ +function getLocationID(matchSetup) { + if (!matchSetup) { + return null; + } + if (matchSetup.location_id) { + return matchSetup.location_id; + } + if (matchSetup.court_id) { + const court = utils.find(curt.courts, c => c._id === matchSetup.court_id); + if (court && court.location_id) { + return court.location_id; + } + } + return null; +} + +function announceNewMatch(matchSetup) { + const location_id = getLocationID(matchSetup); + const calls_enabled = window.localStorage.getItem('enable_announcement_calls_' + location_id) === 'true'; + if (!calls_enabled) { + return; + } + const field = createFieldAnnouncement(matchSetup); + const matchNumber = createMatchNumberAnnouncement(matchSetup); + const eventName = createEventAnnouncement(matchSetup); + const round = createRoundAnnouncement(matchSetup); + const teams = createTeamAnnouncement(matchSetup); + const umpire = createUmpire(matchSetup); + const serviceJudge = createServiceJudge(matchSetup); + const tabletOperator = createTabletOperator(matchSetup); + announce( + [field, matchNumber, eventName, round, teams, umpire, serviceJudge, tabletOperator, field], + false, + buildAnnouncementClaimKey(matchSetup, 'match_called_on_court') + ); +} + +function announcePreparationMatch(matchSetup) { + const location_id = getLocationID(matchSetup); + const preparations_enabled = window.localStorage.getItem('enable_announcement_preparations_' + location_id) === 'true'; + if (!preparations_enabled) { + return; + } + const field = createFieldPreparationAnnouncement(matchSetup); + var preparation = createPreparationAnnouncement(matchSetup); + var matchNumber = createMatchNumberAnnouncement(matchSetup); + var eventName = createEventAnnouncement(matchSetup); + var round = createRoundAnnouncement(matchSetup); + var teams = createTeamAnnouncement(matchSetup); + const umpire = createUmpire(matchSetup); + const serviceJudge = createServiceJudge(matchSetup); + const tabletOperator = createTabletOperator(matchSetup); + var lastPart = preparation; + if (curt.preparation_meetingpoint_enabled) { + lastPart = createMeetingPointAnnouncement(matchSetup); + } + announce( + [preparation, field, matchNumber, eventName, round, teams, umpire, serviceJudge, tabletOperator, lastPart], + false, + buildAnnouncementClaimKey(matchSetup, 'match_preparation_call') + ); +} +function announceSecondCallTeamOne(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + announceSecondCall(matchSetup, matchSetup.teams[0]); +} + +function announceSecondCallTeamTwo(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + announceSecondCall(matchSetup, matchSetup.teams[1]); +} + +function announceSecondPreparationCallTeamOne(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + announceSecondPreparationCall(matchSetup, matchSetup.teams[0]); +} + +function announceSecondPreparationCallTeamTwo(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + announceSecondPreparationCall(matchSetup, matchSetup.teams[1]); +} + +function announceSecondCallTabletoperator(matchSetup) { + if (!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const tabletOperatorCall = createTabletOperator(matchSetup); + if (tabletOperatorCall != null) { + const call = createFieldAnnouncement(matchSetup) + createSecondCallAnnouncement() + tabletOperatorCall; + announce([call]); + } +} +function announceSecondCallUmpire(matchSetup) { + if (!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const umpireCall = createUmpire(matchSetup);; + if (umpireCall != null) { + const call = createFieldAnnouncement(matchSetup) + createSecondCallAnnouncement() + umpireCall; + announce([call]); + } +} +function announceSecondCallServiceJudge(matchSetup) { + if (!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const servicejudgeCall = createServiceJudge(matchSetup);; + if (servicejudgeCall != null) { + const call = createFieldAnnouncement(matchSetup) + createSecondCallAnnouncement() + servicejudgeCall; + announce([call]); + } +} + + +function announceSecondCall(matchSetup, team) { + if(!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + var secondCall = createSecondCallAnnouncement() + createSingleTeam(team.players); + var field = createFieldAnnouncement(matchSetup); + announce([secondCall, field]); +} + + +function announceSecondPreparationCallTabletoperator(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const tabletOperatorCall = createTabletOperator(matchSetup); + if (tabletOperatorCall != null) { + var secondCall = createSecondPreparationCallAnnouncement() + tabletOperatorCall + '!'; + + + var callUs = createSingleTeam(matchSetup.tabletoperators) + ', ' + ci18n('announcements:please_as_tablet_service'); + if (curt.preparation_meetingpoint_enabled) { + var meetingPoint = createMeetingPointAnnouncement(matchSetup); + meetingPoint = meetingPoint.replace("bitte meldet euch ", ""); + meetingPoint = meetingPoint.replace("Bitte meldet euch ", ""); + meetingPoint = meetingPoint.replace("!", ""); + callUs += ' ' + meetingPoint + 'melden!'; + } + announce([secondCall, callUs]); + } +} + + +function announceSecondPreparationCallUmpire(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const umpireCall = createUmpire(matchSetup); + if (umpireCall != null) { + var secondCall = createSecondPreparationCallAnnouncement() + umpireCall + '!'; + + + var callUs = normalizeNames(matchSetup.umpire.name); + if (curt.preparation_meetingpoint_enabled) { + var meetingPoint = createMeetingPointAnnouncement(matchSetup); + meetingPoint = meetingPoint.replace("meldet euch ", "melde dich"); + callUs += ' ' + meetingPoint; + } else { + callUs += ' ' + ci18n('announcements:preparation') + '!'; + } + announce([secondCall, callUs]); + } +} + +function announceSecondPreparationCallServiceJudge(matchSetup) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + const servicejudgeCall = createServiceJudge(matchSetup); + if (servicejudgeCall != null) { + var secondCall = createSecondPreparationCallAnnouncement() + servicejudgeCall + '!'; + + + var callUs = normalizeNames(matchSetup.service_judge.name); + if (curt.preparation_meetingpoint_enabled) { + var meetingPoint = createMeetingPointAnnouncement(matchSetup); + meetingPoint = meetingPoint.replace("meldet euch ", "melde dich"); + callUs += ' ' + meetingPoint; + } else { + callUs += ' ' + ci18n('announcements:preparation') + '!'; + } + announce([secondCall, callUs]); + } +} + + +function announceSecondPreparationCall(matchSetup, team) { + if(!(window.localStorage.getItem('enable_announcement_preparations_' + getLocationID(matchSetup)) === 'true')) { + return; + } + var secondCall = createSecondPreparationCallAnnouncement() + createSingleTeam(team.players); + if(matchSetup.location_id) { + const l = utils.find(curt.locations, l => l._id === matchSetup.location_id); + if(l) { + secondCall += ' ' + l.preparation_addition; + } + } + secondCall += "!"; + var callUs = createSingleTeam(team.players); + if (curt.preparation_meetingpoint_enabled) { + var meetingPoint = createMeetingPointAnnouncement(matchSetup); + if(team.players.length == 1) + { + meetingPoint = meetingPoint.replace("meldet euch", "melde dich"); + } + + callUs += ' ' + meetingPoint; + } + announce([secondCall, callUs]); +} + +function announceBeginnToPlay(matchSetup, team) { + if(!(window.localStorage.getItem('enable_announcement_calls_' + getLocationID(matchSetup)) === 'true')) { + return; + } + announce([createFieldAnnouncement(matchSetup) + ci18n('announcements:begin_to_play')]); +} + +function createSecondCallAnnouncement() { + return ci18n('announcements:second_call') + ' ' + ci18n('announcements:second_call_for') + ':'; +} + +function createSecondPreparationCallAnnouncement() { + return ci18n('announcements:second_call') + ' ' + ci18n('announcements:preparation')+ ' ' + ci18n('announcements:second_call_for') + ':'; +} + +function createTeamAnnouncement(matchSetup) { + var teams = createSingleTeam(matchSetup.teams[0].players) + "," + ci18n('announcements:vs') + createSingleTeam(matchSetup.teams[1].players); + return teams; +} + +function createTabletOperator(matchSetup) { + if (matchSetup.tabletoperators && matchSetup.tabletoperators != null) { + return (curt.tabletoperator_use_manual_counting_boards_enabled ? ci18n('announcements:counting_board_service') : ci18n('announcements:table_service')) + createSingleTeam(matchSetup.tabletoperators); + } + return null; +} + +function createUmpire(matchSetup) { + if (matchSetup.umpire && matchSetup.umpire.name && matchSetup.umpire.name != null) { + return ci18n('announcements:umpire') + normalizeNames(matchSetup.umpire.name); + } + return null; +} + +function createServiceJudge(matchSetup) { + if (matchSetup.service_judge && matchSetup.service_judge.name && matchSetup.service_judge.name != null) { + return ci18n('announcements:service_judge') + normalizeNames(matchSetup.service_judge.name); + } + return null; +} + +function createSingleTeam(playersSetup) { + var team = normalizeNames(playersSetup[0].name); + if (playersSetup.length == 2) { + team = team + ci18n('announcements:and') + normalizeNames(playersSetup[1].name) + } + return team; +} + + +function normalizeNames(name) { + if (curt.normalizations && curt.normalizations.length > 0) { + for (const norm of curt.normalizations) { + if (ci18n('announcements:lang') == norm.language) { + name = name.replaceAll(norm.origin, norm.replace); + } + } + } + return name; +} + +function createRoundAnnouncement(matchSetup) { + if (curt.annoncement_include_round) { + var round = matchSetup.match_name; + if (round == "R16") { + round = ci18n('announcements:round_16'); + } else if (round == "VF") { + round = ci18n('announcements:quaterfinal'); + } else if (round == "HF") { + round = ci18n('announcements:semifinal'); + } else if (round == "Finale") { + round = ci18n('announcements:final'); + } else if (round.indexOf('/') !== -1) { + var roundParts = round.split("/") + var diff = roundParts[1] - roundParts[0]; + if (diff > 1) { + round = ci18n('announcements:round_for_places') + roundParts[0] + ci18n('announcements:to') + roundParts[1]; + } else { + round = ci18n('announcements:game_for_place') + roundParts[0] + ci18n('announcements:and') + roundParts[1]; + } + } else if (round.indexOf('-') !== -1) { + round = ci18n('announcements:intermediate_round'); + } else { + round = ""; + } + return round; + } else { + return null; + } +} +function createEventAnnouncement(matchSetup) { + if (curt.annoncement_include_event) { + var eventParts = matchSetup.event_name.replaceAll("-", " ").split(" "); + var eventName = ""; + if (eventParts[0] == 'JE') { + eventName = ci18n('announcements:boys_singles'); + } else if (eventParts[0] == 'JD') { + eventName = ci18n('announcements:boys_doubles'); + } else if (eventParts[0] == 'ME') { + eventName = ci18n('announcements:girls_singles'); + } else if (eventParts[0] == 'MD') { + eventName = ci18n('announcements:girls_doubles') + } else if (eventParts[0] == 'GD' || eventParts[0] == 'MX') { + eventName = ci18n('announcements:mixed_doubles') + } else if (eventParts[0] == 'HE') { + eventName = ci18n('announcements:men_singles'); + } else if (eventParts[0] == 'HD') { + eventName = ci18n('announcements:men_doubles'); + } else if (eventParts[0] == 'DE') { + eventName = ci18n('announcements:women_singles'); + } else if (eventParts[0] == 'DD') { + eventName = ci18n('announcements:women_doubles'); + } + if (eventName == "") { + if (eventParts[1] == 'JE') { + eventName = ci18n('announcements:boys_singles'); + } else if (eventParts[1] == 'JD') { + eventName = ci18n('announcements:boys_doubles'); + } else if (eventParts[1] == 'ME') { + eventName = ci18n('announcements:girls_singles'); + } else if (eventParts[1] == 'MD') { + eventName = ci18n('announcements:girls_doubles') + } else if (eventParts[1] == 'GD' || eventParts[1] == 'MX') { + eventName = ci18n('announcements:mixed_doubles') + } else if (eventParts[1] == 'HE') { + eventName = ci18n('announcements:men_singles'); + } else if (eventParts[1] == 'HD') { + eventName = ci18n('announcements:men_doubles'); + } else if (eventParts[1] == 'DE') { + eventName = ci18n('announcements:women_singles'); + } else if (eventParts[1] == 'DD') { + eventName = ci18n('announcements:women_doubles'); + } + if (eventParts[0]) { + eventName = eventName + " " + eventParts[0]; + } + } else { + if (eventParts[1]) { + eventName = eventName + " " + eventParts[1]; + } + } + return eventName; + } else { + return null; + } +} + +function createMatchNumberAnnouncement(matchSetup) { + if (curt.annoncement_include_matchnumber) { + var number = matchSetup.match_num; + return ci18n('announcements:match_number') + number + "!"; + } else { + return null; + } +} + +function createFieldAnnouncement(matchSetup) { + if (matchSetup.court_id) { + var court = matchSetup.court_id.split("_")[1]; + return ci18n('announcements:on_court') + court + "!"; + } else { + return ""; + } + +} +function createFieldPreparationAnnouncement(matchSetup) { + if (matchSetup.court_id) { + var court = matchSetup.court_id.split("_")[1]; + return ci18n('announcements:for_court') + court + "!"; + } else { + return ""; + } + +} + +function createPreparationAnnouncement(matchSetup) { + let addition = ""; + if(matchSetup.location_id) { + const l = utils.find(curt.locations, l => l._id === matchSetup.location_id); + if(l) { + addition = ' ' + l.preparation_addition; + } + } + return ci18n('announcements:preparation') + addition; +} + +function createMeetingPointAnnouncement(matchSetup) { + let result = ci18n('announcements:meetingpoint'); + if(matchSetup.location_id) { + const l = utils.find(curt.locations, l => l._id === matchSetup.location_id); + if(l) { + result = l.meetingpoint_announcement; + } + } + + return result; +} + +let emergencyInterval = null; +let emergencyAudio = null; +const ANNOUNCEMENT_DEDUP_TTL_MS = 2500; +const ANNOUNCEMENT_TAB_ID = `${Date.now()}_${Math.random().toString(36).slice(2)}`; +const ANNOUNCEMENT_LEADER_KEY = 'bts_announcement_leader'; +const ANNOUNCEMENT_LEADER_LEASE_MS = 500; +const ANNOUNCEMENT_LEADER_HEARTBEAT_MS = 200; +const ANNOUNCEMENT_RETRY_BROADCAST_KEY = 'bts_announcement_retry_request'; +const ANNOUNCEMENT_SPEECH_CHECK_STATE_KEY = 'bts_announcement_speech_check_state'; +const ANNOUNCEMENT_RETRY_TTL_MS = 5000; +const ANNOUNCEMENT_LEADER_FAILOVER_SUPPRESS_MS = 1500; +const announcementPlaybackQueue = []; +let announcementPlaybackActive = false; +let announcementVoicesPromise = null; +let announcementLeaderHeartbeat = null; +let announcementLeaderLockHeld = false; +let announcementLeaderLockAcquire = null; +let releaseAnnouncementLeaderLock = null; +let announcementLeaderSuppressUntil = 0; +const processedAnnouncementRetryIds = new Set(); +let announcementSpeechCheckState = { + status: 'untested', + detail: '', + updated_at: null, +}; + +function readAnnouncementSpeechCheckStateStorage() { + try { + const raw = window.localStorage.getItem(ANNOUNCEMENT_SPEECH_CHECK_STATE_KEY); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +function writeAnnouncementSpeechCheckStateStorage(state) { + try { + window.localStorage.setItem(ANNOUNCEMENT_SPEECH_CHECK_STATE_KEY, JSON.stringify(state)); + } catch (e) { + // ignore + } +} + +function readAnnouncementLeaderState() { + try { + return JSON.parse(window.localStorage.getItem(ANNOUNCEMENT_LEADER_KEY) || 'null'); + } catch (e) { + return null; + } +} + +function writeAnnouncementLeaderState(owner) { + try { + window.localStorage.setItem(ANNOUNCEMENT_LEADER_KEY, JSON.stringify({ + owner, + expires_at: Date.now() + ANNOUNCEMENT_LEADER_LEASE_MS, + })); + } catch (e) { + // ignore + } +} + +function releaseAnnouncementLeaderIfOwned() { + const current = readAnnouncementLeaderState(); + if (current && current.owner === ANNOUNCEMENT_TAB_ID) { + try { + window.localStorage.removeItem(ANNOUNCEMENT_LEADER_KEY); + } catch (e) { + // ignore + } + } +} + +function refreshAnnouncementLeader() { + if (announcementLeaderSuppressUntil > Date.now()) { + releaseAnnouncementLeaderIfOwned(); + return false; + } + const current = readAnnouncementLeaderState(); + const now = Date.now(); + if (!current || !current.owner || !current.expires_at || current.expires_at <= now || current.owner === ANNOUNCEMENT_TAB_ID) { + writeAnnouncementLeaderState(ANNOUNCEMENT_TAB_ID); + return true; + } + return current.owner === ANNOUNCEMENT_TAB_ID; +} + +function isAnnouncementLeader() { + const current = readAnnouncementLeaderState(); + return !!current && current.owner === ANNOUNCEMENT_TAB_ID && current.expires_at > Date.now(); +} + +function startAnnouncementLeaderHeartbeat() { + if (announcementLeaderHeartbeat != null) { + return; + } + refreshAnnouncementLeader(); + announcementLeaderHeartbeat = window.setInterval(() => { + refreshAnnouncementLeader(); + }, ANNOUNCEMENT_LEADER_HEARTBEAT_MS); + window.addEventListener('visibilitychange', () => { + refreshAnnouncementLeader(); + }); + window.addEventListener('beforeunload', () => { + releaseAnnouncementLeaderIfOwned(); + if (releaseAnnouncementLeaderLock) { + releaseAnnouncementLeaderLock(); + } + }); +} + +function ensureAnnouncementLeaderLock() { + if (announcementLeaderSuppressUntil > Date.now()) { + return Promise.resolve(false); + } + if (!(navigator && navigator.locks && typeof navigator.locks.request === 'function')) { + return Promise.resolve(isAnnouncementLeader() || refreshAnnouncementLeader()); + } + if (announcementLeaderLockHeld) { + return Promise.resolve(true); + } + if (announcementLeaderLockAcquire) { + return announcementLeaderLockAcquire; + } + + announcementLeaderLockAcquire = new Promise((resolve) => { + let resolved = false; + navigator.locks.request('bts-announcement-leader', { mode: 'exclusive', ifAvailable: true }, async (lock) => { + if (!lock) { + resolved = true; + resolve(false); + return false; + } + + announcementLeaderLockHeld = true; + resolved = true; + resolve(true); + + await new Promise((release) => { + releaseAnnouncementLeaderLock = () => { + releaseAnnouncementLeaderLock = null; + announcementLeaderLockHeld = false; + release(); + }; + }); + + return true; + }).catch(() => { + if (!resolved) { + resolve(false); + } + }).finally(() => { + announcementLeaderLockAcquire = null; + }); + }); + + return announcementLeaderLockAcquire; +} + +function buildAnnouncementClaimKey(matchSetup, kind) { + const explicit = matchSetup && matchSetup._announcement_claim_key; + if (explicit) { + return explicit; + } + const matchId = matchSetup && (matchSetup._match_id || matchSetup.match_id || matchSetup.id || matchSetup.btp_id); + return matchId ? `${kind}:${matchId}` : null; +} + +function announcementFingerprint(callArray) { + let hash = 0; + const input = JSON.stringify(callArray || []); + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) - hash) + input.charCodeAt(i); + hash |= 0; + } + return String(hash); +} + +function claimAnnouncementPlaybackSync(callArray, claimKey) { + const now = Date.now(); + const key = `announcement_claim_${claimKey || announcementFingerprint(callArray)}`; + try { + if (!isAnnouncementLeader() && !refreshAnnouncementLeader()) { + return false; + } + const raw = window.localStorage.getItem(key); + if (raw) { + const current = JSON.parse(raw); + if (current && current.expires_at && current.expires_at > now) { + return false; + } + } + const claim = JSON.stringify({ + owner: ANNOUNCEMENT_TAB_ID, + expires_at: now + ANNOUNCEMENT_DEDUP_TTL_MS + }); + window.localStorage.setItem(key, claim); + const confirmed = JSON.parse(window.localStorage.getItem(key) || 'null'); + return !!confirmed && confirmed.owner === ANNOUNCEMENT_TAB_ID; + } catch (e) { + return true; + } +} + +function releaseAnnouncementPlaybackClaim(callArray, claimKey) { + const key = `announcement_claim_${claimKey || announcementFingerprint(callArray)}`; + try { + const raw = window.localStorage.getItem(key); + if (!raw) { + return; + } + const current = JSON.parse(raw); + if (current && current.owner === ANNOUNCEMENT_TAB_ID) { + window.localStorage.removeItem(key); + } + } catch (e) { + // ignore + } +} + +function claimAnnouncementPlayback(callArray, claimKey) { + const lockName = `bts-announcement-claim:${claimKey || announcementFingerprint(callArray)}`; + if (!(navigator && navigator.locks && typeof navigator.locks.request === 'function')) { + return Promise.resolve(claimAnnouncementPlaybackSync(callArray, claimKey)); + } + return navigator.locks.request(lockName, { mode: 'exclusive' }, () => { + return claimAnnouncementPlaybackSync(callArray, claimKey); + }); +} + +function emergency_announce(enable) { + + + if (enable) { + // Verhindert mehrfaches Starten + if (emergencyInterval !== null) { + return; + } + + emergencyAudio = new Audio('/static/audio/evakuierung.mp3'); + + // Sofort abspielen + emergencyAudio.play(); + + // Wiederholung alle 30 Sekunden + emergencyInterval = setInterval(() => { + emergencyAudio.currentTime = 0; + emergencyAudio.play(); + }, 20_000); + + } else { + // Timer stoppen + if (emergencyInterval !== null) { + clearInterval(emergencyInterval); + emergencyInterval = null; + } + + // Audio stoppen + if (emergencyAudio) { + emergencyAudio.pause(); + emergencyAudio.currentTime = 0; + emergencyAudio = null; + } + } +} + +function getAnnouncementVoices() { + if (announcementVoicesPromise) { + return announcementVoicesPromise; + } + announcementVoicesPromise = new Promise(function (resolve) { + let voices = window.speechSynthesis.getVoices(); + if (voices.length !== 0) { + resolve(voices); + return; + } + const onVoicesChanged = function () { + window.speechSynthesis.removeEventListener("voiceschanged", onVoicesChanged); + voices = window.speechSynthesis.getVoices(); + resolve(voices); + }; + window.speechSynthesis.addEventListener("voiceschanged", onVoicesChanged); + }); + return announcementVoicesPromise; +} + +function findAnnouncementVoice(voices) { + for (let i = 0; i < voices.length; i++) { + if (voices[i].voiceURI == ci18n('announcements:voice')) { + return voices[i]; + } + } + return null; +} + +function setAnnouncementSpeechCheckState(status, detail) { + announcementSpeechCheckState = { + status, + detail: detail || '', + updated_at: Date.now(), + }; + writeAnnouncementSpeechCheckStateStorage(announcementSpeechCheckState); + window.dispatchEvent(new CustomEvent('announcement-speech-check-state-changed', { + detail: getAnnouncementSpeechCheckState(), + })); + return announcementSpeechCheckState; +} + +function getAnnouncementSpeechCheckState() { + const storedState = readAnnouncementSpeechCheckStateStorage(); + if (storedState && (!announcementSpeechCheckState.updated_at || (storedState.updated_at || 0) > (announcementSpeechCheckState.updated_at || 0))) { + announcementSpeechCheckState = storedState; + } + return { ...announcementSpeechCheckState }; +} + +function runAnnouncementSpeechCheck() { + if (!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance === 'function')) { + return Promise.resolve(setAnnouncementSpeechCheckState('unsupported', ci18n('announcements:speechcheck:unsupported'))); + } + + return getAnnouncementVoices().then((voices) => { + const voice = findAnnouncementVoice(voices); + return new Promise((resolve) => { + let done = false; + let started = false; + const startedAt = Date.now(); + const finish = (status, detail) => { + if (done) { + return; + } + done = true; + resolve(setAnnouncementSpeechCheckState(status, detail)); + }; + + const words = new SpeechSynthesisUtterance(ci18n('announcements:speechcheck:text')); + words.lang = ci18n('announcements:lang'); + words.rate = curt.announcement_speed ? curt.announcement_speed : 1.05; + words.pitch = 0; + words.volume = 1; + words.voice = voice; + + const timeout = window.setTimeout(() => { + finish('timeout', ci18n('announcements:speechcheck:timeout')); + }, 4000); + + const wrappedFinish = (status, detail) => { + window.clearTimeout(timeout); + finish(status, detail); + }; + words.onstart = function () { + started = true; + }; + words.onend = function () { + const elapsed = Date.now() - startedAt; + if (!started || elapsed < 300) { + wrappedFinish('suspicious', ci18n('announcements:speechcheck:suspicious')); + return; + } + wrappedFinish('active', ci18n('announcements:speechcheck:ok')); + }; + words.onerror = function (event) { + const suffix = event && event.error ? ` (${event.error})` : ''; + wrappedFinish('error', ci18n('announcements:speechcheck:error') + suffix); + }; + + try { + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(words); + } catch (e) { + window.clearTimeout(timeout); + finish('error', ci18n('announcements:speechcheck:error')); + } + }); + }).catch(() => { + return setAnnouncementSpeechCheckState('error', ci18n('announcements:speechcheck:error')); + }); +} + +function playAnnouncementBatch(parts, voice, done, claimKey) { + const filteredParts = (parts || []).filter((part) => !!part); + let index = 0; + let batchStarted = false; + let batchStatus = 'ok'; + const playNext = () => { + if (index >= filteredParts.length) { + done(batchStarted ? batchStatus : 'suspicious'); + return; + } + let utteranceStarted = false; + let utteranceStartedAt = 0; + const words = new SpeechSynthesisUtterance(filteredParts[index]); + words.lang = ci18n('announcements:lang'); + words.rate = curt.announcement_speed ? curt.announcement_speed : 1.05; + words.pitch = 0; + words.volume = 1; + words.voice = voice; + words.onstart = function () { + batchStarted = true; + utteranceStarted = true; + utteranceStartedAt = Date.now(); + console.log('[bts] announcement utterance start', { + claimKey: claimKey || null, + index, + part: filteredParts[index], + visibilityState: document.visibilityState, + }); + }; + words.onend = function () { + const elapsed = utteranceStartedAt ? (Date.now() - utteranceStartedAt) : 0; + if (!utteranceStarted || elapsed < 300) { + if (batchStatus !== 'error') { + batchStatus = 'suspicious'; + } + } + console.log('[bts] announcement utterance end', { + claimKey: claimKey || null, + index, + part: filteredParts[index], + visibilityState: document.visibilityState, + }); + index += 1; + playNext(); + }; + words.onerror = function (event) { + batchStatus = 'error'; + console.log('[bts] announcement utterance error', { + claimKey: claimKey || null, + index, + part: filteredParts[index], + error: event && event.error ? event.error : null, + visibilityState: document.visibilityState, + }); + index += 1; + playNext(); + }; + window.speechSynthesis.speak(words); + }; + playNext(); +} + +function releaseAnnouncementLeaderForFailover() { + announcementLeaderSuppressUntil = Date.now() + ANNOUNCEMENT_LEADER_FAILOVER_SUPPRESS_MS; + releaseAnnouncementLeaderIfOwned(); + if (releaseAnnouncementLeaderLock) { + releaseAnnouncementLeaderLock(); + } +} + +function requestAnnouncementRetry(batch) { + if (!batch || !batch.claimKey || (batch.retryCount || 0) >= 1) { + return; + } + const request = { + id: `${batch.claimKey}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + claimKey: batch.claimKey, + callArray: batch.callArray, + excludeTabId: ANNOUNCEMENT_TAB_ID, + retryCount: (batch.retryCount || 0) + 1, + expiresAt: Date.now() + ANNOUNCEMENT_RETRY_TTL_MS, + }; + try { + window.localStorage.setItem(ANNOUNCEMENT_RETRY_BROADCAST_KEY, JSON.stringify(request)); + } catch (e) { + // ignore + } +} + +function handleAnnouncementRetryRequest(request) { + if (!request || !request.id || processedAnnouncementRetryIds.has(request.id)) { + return; + } + processedAnnouncementRetryIds.add(request.id); + if (request.excludeTabId === ANNOUNCEMENT_TAB_ID) { + return; + } + if (request.expiresAt && request.expiresAt < Date.now()) { + return; + } + announce(request.callArray || [], false, request.claimKey, { + retryCount: request.retryCount || 0, + allowRetry: false, + }); +} + +window.addEventListener('storage', function(event) { + if (event.key === ANNOUNCEMENT_SPEECH_CHECK_STATE_KEY && event.newValue) { + try { + const state = JSON.parse(event.newValue); + if (state && (!announcementSpeechCheckState.updated_at || (state.updated_at || 0) >= (announcementSpeechCheckState.updated_at || 0))) { + announcementSpeechCheckState = state; + window.dispatchEvent(new CustomEvent('announcement-speech-check-state-changed', { + detail: getAnnouncementSpeechCheckState(), + })); + } + } catch (e) { + // ignore + } + return; + } + if (event.key !== ANNOUNCEMENT_RETRY_BROADCAST_KEY || !event.newValue) { + return; + } + try { + handleAnnouncementRetryRequest(JSON.parse(event.newValue)); + } catch (e) { + // ignore + } +}); + +function processAnnouncementPlaybackQueue() { + if (announcementPlaybackActive) { + return; + } + const nextBatch = announcementPlaybackQueue.shift(); + if (!nextBatch) { + return; + } + announcementPlaybackActive = true; + getAnnouncementVoices().then((voices) => { + const voice = findAnnouncementVoice(voices); + console.log('[bts] announcement batch start', { + claimKey: nextBatch.claimKey || null, + parts: (nextBatch.callArray || []).filter(Boolean), + }); + playAnnouncementBatch(nextBatch.callArray, voice, (status) => { + console.log('[bts] announcement batch end', { + claimKey: nextBatch.claimKey || null, + }); + if (status === 'ok' || status === 'active') { + setAnnouncementSpeechCheckState('active', ci18n('announcements:speechcheck:ok')); + } else if (status === 'suspicious') { + setAnnouncementSpeechCheckState('suspicious', ci18n('announcements:speechcheck:suspicious')); + } else if (status === 'error') { + setAnnouncementSpeechCheckState('error', ci18n('announcements:speechcheck:error')); + } + if ((status === 'suspicious' || status === 'error') && nextBatch.allowRetry !== false) { + releaseAnnouncementPlaybackClaim(nextBatch.callArray, nextBatch.claimKey); + releaseAnnouncementLeaderForFailover(); + requestAnnouncementRetry(nextBatch); + } + const pauseMs = curt.announcement_pause_time_ms ? (curt.announcement_pause_time_ms * 1000) : 2000; + setTimeout(() => { + announcementPlaybackActive = false; + processAnnouncementPlaybackQueue(); + }, pauseMs); + }, nextBatch.claimKey); + }).catch(() => { + console.log('[bts] announcement batch end', { + claimKey: nextBatch.claimKey || null, + error: true, + }); + announcementPlaybackActive = false; + processAnnouncementPlaybackQueue(); + }); +} + +function announce(callArray, local, claimKey, options) { + const enqueue = (enqueueOptions) => { + const resolvedOptions = enqueueOptions || {}; + announcementPlaybackQueue.push({ + callArray, + claimKey, + retryCount: resolvedOptions.retryCount || 0, + allowRetry: resolvedOptions.allowRetry !== false, + }); + if (!local) { + console.log(`[bts] announcement played ${claimKey}`); + } + processAnnouncementPlaybackQueue(); + }; + + if (local) { + enqueue({}); + return; + } + + Promise.resolve(ensureAnnouncementLeaderLock()).then((isLeader) => { + if (!isLeader) { + console.log(`[bts] announcement skipped ${claimKey}`); + return; + } + return Promise.resolve(claimAnnouncementPlayback(callArray, claimKey)).then((claimed) => { + if (!claimed) { + console.log(`[bts] announcement skipped ${claimKey}`); + return; + } + enqueue(options || {}); + }); + }).catch(() => { + console.log(`[bts] announcement skipped ${claimKey}`); + }); +} diff --git a/static/js/cerror.js b/static/js/cerror.js index 2f2ee2c..8c812bc 100644 --- a/static/js/cerror.js +++ b/static/js/cerror.js @@ -3,97 +3,67 @@ var cerror = (function() { -var REPORT_URL = 'https://aufschlagwechsel.de/bupbug/'; -var count = -1; -var error_list = []; -var report_enabled = true; - -function show(msg) { - error_list.push(msg); - if (typeof uiu !== 'undefined') { - uiu.show_qs('.errors'); - uiu.text_qs('.errors', error_list.join('\n')); - } -} -function get_platform_info() { - return { - size: document.documentElement.clientWidth + 'x' + document.documentElement.clientHeight, - ua: window.navigator.userAgent, - }; -} + var error_list = []; + var report_enabled = true; + + function show(msg) { + error_list.unshift(msg); + if (typeof uiu !== 'undefined') { + try { + uiu.show_qs('.errors'); + uiu.text_qs('.errors', error_list.join('\n')); + } catch (e) { } + } + } -function on_error(msg, script_url, line, col, err) { - show(msg); - if (! report_enabled) { - return; + function on_error(msg, script_url, line, col, err) { + show(getCurrentTimeString() + ' - ' + msg); } - count++; - if (count > 5) { - return; + function silent(msg) { + console.error(msg); + on_error(msg, undefined, undefined, undefined, new Error()); } - var report = { - msg, - count, - _type: 'bts-error', - bts_type: 'client', - platform: get_platform_info(), - }; - if (script_url !== undefined) { - report.script_url = script_url; - } - if (line !== undefined) { - report.line = line; + function net(err) { + silent(err.message); } - if (col !== undefined) { - report.col = col; - } - if (err) { - report.stack = err.stack; + + function init() { + var report_enabled_json = document.getElementById('bts-data-holder').getAttribute('data-error-reporting'); + try { + report_enabled = JSON.parse(report_enabled_json); + } catch(e) { + silent('Error reporting JSON invalid: ' + report_enabled_json); + return; + } + if (report_enabled === null) { + silent('Error reporting not configured'); + return; + } + if (report_enabled) { + window.onerror = on_error; + } } - var report_json = JSON.stringify(report); - var xhr = new XMLHttpRequest(); - xhr.open('POST', REPORT_URL, true); - xhr.setRequestHeader('Content-type', 'text/plain'); // To be a simple CORS request (avoid CORS preflight) - xhr.send(report_json); -} + return { + init, + net, + on_error, + silent, + }; -function silent(msg) { - console.error(msg); // eslint-disable-line no-console - on_error(msg, undefined, undefined, undefined, new Error()); -} + function getCurrentTimeString() { + const now = new Date(); -function net(err) { - silent(err.message); -} + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); -function init() { - var report_enabled_json = document.getElementById('bts-data-holder').getAttribute('data-error-reporting'); - try { - report_enabled = JSON.parse(report_enabled_json); - } catch(e) { - silent('Error reporting JSON invalid: ' + report_enabled_json); - return; + return `${hh}:${mm}:${ss}`; } - if (report_enabled === null) { - silent('Error reporting not configured'); - return; - } - if (report_enabled) { - window.onerror = on_error; - } -} - -return { - init, - net, - on_error, - silent, -}; })(); diff --git a/static/js/change.js b/static/js/change.js index b7bee05..84392b3 100644 --- a/static/js/change.js +++ b/static/js/change.js @@ -6,139 +6,649 @@ function default_handler(rerender, special_funcs) { }; } -function change_score(cval) { - const match_id = cval.match_id; - - // Find the match - const m = utils.find(curt.matches, m => m._id === match_id); - if (!m) { - cerror.silent('Cannot find match to update score, ID: ' + JSON.stringify(match_id)); - return; + function _announcement_claim_key(change_obj, fallback_kind) { + if (!change_obj || !change_obj.val) { + return `${fallback_kind}:na`; + } + if (change_obj.val._announcement_claim_key) { + return change_obj.val._announcement_claim_key; + } + const ts = change_obj.val._announcement_ts || 'na'; + const match_id = + change_obj.val.match__id || + change_obj.val._id || + change_obj.val.btp_id || + (change_obj.val.match && change_obj.val.match._id) || + (change_obj.val.setup && (change_obj.val.setup._match_id || change_obj.val.setup.btp_id)) || + 'unknown'; + return `${fallback_kind}:${match_id}:${ts}`; } - m.network_score = cval.network_score; - m.presses = cval.presses; - m.team1_won = cval.team1_won; -} - -function change_current_match(cval) { - // Do not use courts_by_id since that may not be initialized in all views - const court = utils.find(curt.courts, c => c._id === cval.court_id); - if (court) { - court.match_id = cval.match_id; - } else { - cerror.silent('Cannot find court ' + JSON.stringify(cval.court_id)); + function _attach_setup_announcement_claim(change_obj, fallback_kind) { + if (!change_obj || !change_obj.val || !change_obj.val.setup) { + return; + } + change_obj.val.setup._announcement_claim_key = _announcement_claim_key(change_obj, fallback_kind); + change_obj.val.setup._match_id = + change_obj.val.match__id || + change_obj.val._id || + change_obj.val.btp_id || + change_obj.val.setup._match_id; } -} -function default_handler_func(rerender, special_funcs, c) { - if (special_funcs && special_funcs[c.ctype]) { - special_funcs[c.ctype](c); - return; + function _handle_announcement_event(kind, payload, announce_fn) { + if (ctournament && typeof ctournament.handle_view_announcement === 'function') { + const handled = ctournament.handle_view_announcement(kind, payload); + if (handled) { + return; + } + } + announce_fn(); } - switch (c.ctype) { - case 'props': { - curt.name = c.val.name; - curt.is_team = c.val.is_team; - curt.is_nation_competition = c.val.is_nation_competition; - curt.only_now_on_court = c.val.only_now_on_court; - curt.btp_timezone = c.val.btp_timezone; - curt.btp_enabled = c.val.btp_enabled; - curt.btp_autofetch_enabled = c.val.btp_autofetch_enabled; - curt.btp_readonly = c.val.btp_readonly; - curt.btp_ip = c.val.btp_ip; - curt.ticker_enabled = c.val.ticker_enabled; - curt.ticker_url = c.val.ticker_url; - curt.ticker_password = c.val.ticker_password; - curt.logo_id = c.val.logo_id; - - uiu.qsEach('.ct_name', function(el) { - if (el.tagName.toUpperCase() === 'INPUT') { - el.value = c.val.name; + function _apply_tournament_field_change(field, value) { + curt[field] = value; + + if (field === 'name') { + uiu.qsEach('.ct_name', function(el) { + if (el.tagName.toUpperCase() === 'INPUT') { + el.value = value || ''; + } else { + uiu.text(el, value || ''); + } + }); + } + + uiu.qsEach('input[name="' + field + '"]', function(el) { + if (el.type === 'checkbox') { + el.checked = !!value; } else { - uiu.text(el, c.val.name); + el.value = value ?? ''; } }); - const CHECKBOXES = [ - 'is_team', 'is_nation_competition', 'only_now_on_court', - 'btp_enabled', 'btp_autofetch_enabled', 'btp_readonly', - 'ticker_enabled']; - for (const cb_name of CHECKBOXES) { - uiu.qsEach('input[name="' + cb_name + '"]', function(el) { - el.checked = curt[cb_name]; - }); - } - uiu.qsEach('input[name="btp_ip"]', function(el) { - el.value = curt.btp_ip; + uiu.qsEach('select[name="' + field + '"]', function(el) { + el.value = value ?? (field === 'btp_timezone' ? 'system' : ''); }); + } - uiu.qsEach('input[name="ticker_url"]', function(el) { - el.value = curt.ticker_url; + function _after_tournament_field_change(field, value, change_obj) { + if (current_view === 'edit') { + ctournament.update_edit_dependencies(); + } + if (field === 'displaysettings_general') { + ctournament.update_general_displaysettings(change_obj); + } + if (field === 'language') { + if (value && value !== 'auto') { + ci18n.switch_language(value); + } + ctournament.refresh_current_view(); + } + if (field === 'tabletoperator_enabled') { + ctournament.refresh_current_view(); + } + if (field === 'official_rotation_mode' && current_view === 'edit') { + ctournament.update_officials(); + } + if (['btp_enabled', 'ticker_enabled'].includes(field)) { + if (current_view === 'show') { + ctournament.update_metadata_settings(); + } + } + if ([ + 'automation_enabled', + 'official_rotation_mode', + 'call_preparation_matches_automatically_enabled', + 'call_next_possible_scheduled_match_in_preparation', + 'tabletoperator_enabled', + ].includes(field) && current_view === 'show') { + ctournament.update_show_automation_controls(); + } + } + + function _official_pause_target_field(list_name) { + if (list_name === 'umpire_pause') { + return 'umpire_manual_pause'; + } + if (list_name === 'service_judge_pause') { + return 'service_judge_manual_pause'; + } + return list_name; + } + + function change_score(cval) { + const match_id = cval.match_id; + + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update score, ID: ' + JSON.stringify(match_id)); + return; + } + + m.network_score = cval.network_score; + m.presses = cval.presses; + m.team1_won = cval.team1_won; + } + + function apply_umpires_changed(update, deps) { + const helper = (typeof change_helpers !== 'undefined' && change_helpers && change_helpers.apply_umpires_changed) + ? change_helpers.apply_umpires_changed + : null; + const resolved_deps = deps || { + curt_ref: curt, + uiu_ref: uiu, + cmatch_ref: cmatch, + current_view_ref: current_view, + cumpires_ref: cumpires, + ctournament_ref: ctournament + }; + if (helper) { + helper(update, resolved_deps); + return; + } + resolved_deps.curt_ref.umpires = (update.all_umpires || []).map((official) => { + if (resolved_deps.ctournament_ref && typeof resolved_deps.ctournament_ref.apply_pending_official_role_override === 'function') { + return resolved_deps.ctournament_ref.apply_pending_official_role_override(official); + } + return official; }); - uiu.qsEach('input[name="ticker_password"]', function(el) { - el.value = curt.ticker_password; + resolved_deps.uiu_ref.qsEach('select[name="umpire_name"]', function(select) { + resolved_deps.cmatch_ref.render_umpire_options(select, select.value); }); + if(resolved_deps.current_view_ref === 'show') { + resolved_deps.cumpires_ref.ui_status(resolved_deps.uiu_ref.qs('.umpire_container')); + } + resolved_deps.ctournament_ref.update_officials(); + } - break;} - case 'match_add': - curt.matches.push(c.val.match); - rerender(); - break; - case 'match_edit': - { - const changed_m = utils.find(curt.matches, m => m._id === c.val.match__id); - if (changed_m) { - changed_m.setup = c.val.setup; - } else { - cerror.silent('Cannot find edited match ' + c.val.match__id); - } - rerender(); - } - break; - case 'match_delete': - { - const match_id = c.val.match__id; - const deleted = utils.remove_cb(curt.matches, m => m._id === match_id); - if (!deleted) { - cerror.silent('Cannot find deleted match ' + match_id); - } - rerender(); - } - break; - case 'courts_changed': - curt.courts = c.val.all_courts; - rerender(); - break; - case 'umpires_changed': - curt.umpires = c.val.all_umpires; - uiu.qsEach('select[name="umpire_name"]', function(select) { - cmatch.render_umpire_options(select, select.value); - }); - break; - case 'score': - change_score(c.val); - // Most dialogs don't show any matches, so do not rerender - break; - case 'court_current_match': - change_current_match(c.val); - // Most dialogs don't show any matches, so do not rerender - break; - case 'btp_status': - uiu.text_qs('.btp_status', 'BTP status: ' + c.val); - break; - case 'ticker_status': - uiu.text_qs('.ticker_status', 'Ticker status: ' + c.val); - break; - default: - cerror.silent('Unsupported change type ' + c.ctype); + function default_handler_func(rerender, special_funcs, c) { + if (special_funcs && special_funcs[c.ctype]) { + special_funcs[c.ctype](c); + return; + } + + switch (c.ctype) { + case 'free_announce': + _handle_announcement_event('free_announce', c.val, () => { + if (!(window.localStorage.getItem('enable_free_announcements') === 'true')) { + return; + } + announce([c.val.text], false, _announcement_claim_key(c, 'free_announce')); + }); + break; + case 'emergency_announce': + curt.enable_emergency = c.val; + _handle_announcement_event('emergency_announce', c.val, () => emergency_announce(c.val)); + if (current_view === 'show'){ + ctournament.update_emergency_btn() + } + if(curt.enable_emergency) { + cerror.silent('Evakuierungsdurchsage wird gestartet!'); + } else { + cerror.silent('Evakuierungsdurchsage wurde gestoppt'); + } + break; + case 'props': { + for (const [field, value] of Object.entries(c.val)) { + _apply_tournament_field_change(field, value); + } + + if (c.val.scoring_formats) { + ctournament.close_scoring_format_dialog_if_open(undefined, 'tournament:edit:scoring_formats:dialog_closed_external_change'); + ctournament.update_scoring_formats(); + ctournament.update_stages_scoring_formats(); + } + for (const [field, value] of Object.entries(c.val)) { + _after_tournament_field_change(field, value, c); + } + break; + } + case 'prop_changed': { + const field = c.val.field; + _apply_tournament_field_change(field, c.val.value); + _after_tournament_field_change(field, c.val.value, c); + break; + } + case 'logo_changed': + if(c.val.logo_background_color != undefined) { + curt.logo_background_color = c.val.logo_background_color; + } + if(c.val.logo_foreground_color != undefined) { + curt.logo_foreground_color = c.val.logo_foreground_color; + } + if(c.val.logo_id != undefined) { + curt.logo_id = c.val.logo_id; + } + if(c.val.logo_name != undefined) { + curt.logo_name = c.val.logo_name; + } + ctournament.update_logo(); + break; + case 'court_current_match': + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'tabletoperator_add': + //nothing to do here + break; + case 'tabletoperator_moved_up': + //nothing todo here + break; + case 'tabletoperator_moved_down': + //nothing todo here + break; + case 'tabletoperator_removed': + //nothing todo here + break; + case 'match_edit': + ctournament.update_match(c); + ctournament.update_officials(); + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'match_add': + const match_id = c.val.match__id; + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + ctournament.add_match(c); + curt.matches.push(c.val.match); + curt.matches = curt.matches.sort( (a, b) => cmatch.cmp_match_order(a, b)); + } else { + ctournament.add_match(c); + } + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'match_delete': + { + const match_id = c.val.match__id; + const deleted = utils.remove_cb(curt.matches, m => m._id === match_id); + if (!deleted) { + cerror.silent('Cannot find deleted match ' + match_id); + } + rerender(); + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + } + break; + case 'courts_changed': + curt.courts = c.val.all_courts; + rerender(); + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'court_changed': + const court = utils.find(curt.courts, court => court._id === c.val.court_id); + if(court) { + court.is_active = c.val.is_active; + court.has_umpire = c.val.has_umpire; + court.has_service_judge = c.val.has_service_judge; + if (Object.prototype.hasOwnProperty.call(c.val, 'match_id')) { + court.match_id = c.val.match_id; + } + } + ctournament.update_court(court); + if (ctournament && typeof ctournament.update_officials === 'function') { + ctournament.update_officials(); + } + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'locations_changed': + curt.locations = c.val.all_locations; + rerender(); + break; + case 'location_changed': + const l = utils.find(curt.locations, l => l._id === c.val.location_id); + if(l) { + l.highlight = c.val.highlight; + l.preparation_addition = c.val.preparation_addition; + l.meetingpoint_announcement = c.val.meetingpoint_announcement; + } + ctournament.update_location(c.val.location_id, c.val.highlight, c.val.preparation_addition, c.val.meetingpoint_announcement); + break; + case 'location_highlight_changed': + const old_location_highlight = c.val.old_location_highlight; + const new_location_highlight = c.val.new_location_highlight; + + curt.matches.forEach((match) => { + if(match.setup.highlight == old_location_highlight && match.setup.state === 'preparation'){ + match.setup.highlight = new_location_highlight; + c.val = { match__id: match._id, match} + ctournament.update_match(c); + ctournament.update_upcoming_match(c); + } + }); + break; + case 'location_logo_changed': + const loc = utils.find(curt.locations, loc => loc._id === c.val.location_id); + if(loc) { + loc.logo_name = c.val.logo_name; + loc.logo_id = c.val.logo_id; + } + ctournament.update_location_logo(c.val.location_id, loc.logo_id, loc.logo_name); + break; + case 'match_preparation_call': + if (c.val && c.val.match && c.val.match.setup) { + c.val.match.setup._announcement_claim_key = `match_preparation_call:${c.val.match__id}:${c.val._announcement_ts || 'na'}`; + c.val.match.setup._match_id = c.val.match__id; + } + _handle_announcement_event('match_preparation_call', c.val, () => announcePreparationMatch(c.val.match.setup)); + ctournament.update_match(c); + ctournament.update_upcoming_match(c); + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + break; + case 'match_called_on_court': + _attach_setup_announcement_claim(c, 'match_called_on_court'); + _handle_announcement_event('match_called_on_court', c.val, () => announceNewMatch(c.val.setup)); + break; + case 'begin_to_play_call': + _attach_setup_announcement_claim(c, 'begin_to_play_call'); + _handle_announcement_event('begin_to_play_call', c.val, () => announceBeginnToPlay(c.val.setup)); + break; + case 'second_call_tabletoperator': + _attach_setup_announcement_claim(c, 'second_call_tabletoperator'); + _handle_announcement_event('second_call_tabletoperator', c.val, () => announceSecondCallTabletoperator(c.val.setup)); + break; + case 'second_call_umpire': + _attach_setup_announcement_claim(c, 'second_call_umpire'); + _handle_announcement_event('second_call_umpire', c.val, () => announceSecondCallUmpire(c.val.setup)); + break; + case 'second_call_servicejudge': + _attach_setup_announcement_claim(c, 'second_call_servicejudge'); + _handle_announcement_event('second_call_servicejudge', c.val, () => announceSecondCallServiceJudge(c.val.setup)); + break; + case 'second_call_team_one': + _attach_setup_announcement_claim(c, 'second_call_team_one'); + _handle_announcement_event('second_call_team_one', c.val, () => announceSecondCallTeamOne(c.val.setup)); + break; + case 'second_call_team_two': + _attach_setup_announcement_claim(c, 'second_call_team_two'); + _handle_announcement_event('second_call_team_two', c.val, () => announceSecondCallTeamTwo(c.val.setup)); + break; + case 'second_preparation_call_tabletoperator': + _attach_setup_announcement_claim(c, 'second_preparation_call_tabletoperator'); + _handle_announcement_event('second_preparation_call_tabletoperator', c.val, () => announceSecondPreparationCallTabletoperator(c.val.setup)); + break; + case 'second_preparation_call_umpire': + _attach_setup_announcement_claim(c, 'second_preparation_call_umpire'); + _handle_announcement_event('second_preparation_call_umpire', c.val, () => announceSecondPreparationCallUmpire(c.val.setup)); + break; + case 'second_preparation_call_servicejudge': + _attach_setup_announcement_claim(c, 'second_preparation_call_servicejudge'); + _handle_announcement_event('second_preparation_call_servicejudge', c.val, () => announceSecondPreparationCallServiceJudge(c.val.setup)); + break; + case 'second_preparation_call_team_one': + _attach_setup_announcement_claim(c, 'second_preparation_call_team_one'); + _handle_announcement_event('second_preparation_call_team_one', c.val, () => announceSecondPreparationCallTeamOne(c.val.setup)); + break; + case 'second_preparation_call_team_two': + _attach_setup_announcement_claim(c, 'second_preparation_call_team_two'); + _handle_announcement_event('second_preparation_call_team_two', c.val, () => announceSecondPreparationCallTeamTwo(c.val.setup)); + break; + case 'btp_status': + ctournament.btp_status_changed(c); + break; + case 'ticker_status': + ctournament.ticker_status_changed(c); + break; + case 'bts_status': + ctournament.bts_status_changed(c); + break; + case 'queue_hang_warning': + cerror.silent('BTS hängt in Aufgabe "' + c.val.task + '" seit ' + Math.round((c.val.runtime_ms || 0) / 1000) + 's.'); + break; + case 'normalization_add': + ctournament.add_normalization(c); + break; + case 'normalization_removed': + ctournament.remove_normalization(c); + break; + case 'advertisement_add': + ctournament.add_advertisement(c); + break; + case 'advertisement_removed': + ctournament.remove_advertisement(c); + break; + case 'update_player_status': { + const cval = c.val; + const id = cval.match__id; + + const m = utils.find(curt.matches, m => m._id === id); + if (!m) { + cerror.silent('Cannot find match to update player status, ID: ' + JSON.stringify(id)); + return; + } + + m.btp_winner = cval.btp_winner; + m.setup = cval.setup; + cmatch.update_players(m); + break; + } + case 'umpires_changed': + apply_umpires_changed(c.val); + break; + case 'umpire_updated': + const umpire = c.val; + if (ctournament && typeof ctournament.apply_pending_official_role_override === 'function') { + ctournament.apply_pending_official_role_override(umpire); + } + const u = utils.find(curt.umpires, m => m._id === umpire._id); + if (u) { + u.last_time_on_court_ts = umpire.last_time_on_court_ts; + u.firstname = umpire.firstname; + u.surname = umpire.surname; + u.name = umpire.name; + u.country = umpire.country; + u.status = umpire.status; + u.court_id = umpire.court_id; + u.is_umpire = umpire.is_umpire; + u.is_service_judge = umpire.is_service_judge; + u.is_planed_as_umpire = umpire.is_planed_as_umpire; + u.is_planed_as_service_judge = umpire.is_planed_as_service_judge; + u.umpire_on_court = umpire.umpire_on_court; + u.service_judge_on_court = umpire.service_judge_on_court; + u.umpire_wait = umpire.umpire_wait; + u.service_judge_wait = umpire.service_judge_wait; + u.umpire_pause = umpire.umpire_pause; + u.service_judge_pause = umpire.service_judge_pause; + u.umpire_manual_pause = umpire.umpire_manual_pause; + u.service_judge_manual_pause = umpire.service_judge_manual_pause; + u.inactive_list = umpire.inactive_list; + u.checked_in = umpire.checked_in; + } else { + curt.umpires.push(umpire); + } + if(current_view === 'show') { + cumpires.ui_status(uiu.qs('.umpire_container')); + } + ctournament.update_officials(); + break; + case 'umpire_add': + const added_umpire = c.val.umpire; + curt.umpires.push(added_umpire); + if(current_view === 'show') { + cumpires.ui_status(uiu.qs('.umpire_container')); + } + ctournament.update_officials(); + break; + case 'umpire_removed': + const removed_umpire = c.val.umpire; + const ru = utils.find(curt.umpires, m => m._id === removed_umpire._id); + curt.umpires.splice(curt.umpires.indexOf(ru), 1); + if(current_view === 'show') { + cumpires.ui_status(uiu.qs('.umpire_container')); + } + ctournament.update_officials(); + break; + case 'official_list_move': + const official = utils.find(curt.umpires, u => u._id === c.val.official_id); + if (!official) { + break; + } + official.inactive_list = null; + official.service_judge_pause = null; + official.umpire_pause = null; + official.service_judge_manual_pause = null; + official.umpire_manual_pause = null; + official.service_judge_wait = null; + official.umpire_wait = null; + official.service_judge_on_court = null; + official.umpire_on_court = null; + official.is_planed_as_service_judge = false; + official.is_planed_as_umpire = false; + official[_official_pause_target_field(c.val.to_list)] = c.val.new_ts; + ctournament.update_officials(); + break; + case 'official_edit': + const official_edit = utils.find(curt.umpires, u => u._id === c.val.official_id); + official_edit[c.val.field] = c.val.value; + ctournament.update_officials(); + break; + case 'score': + change_score(c.val); + if (current_view === 'show' && ctournament && typeof ctournament.update_location_preparation_need_labels === 'function') { + ctournament.update_location_preparation_need_labels(); + } + // Most dialogs don't show any matches, so do not rerender + break; + case 'update_btp_settings': + if(!curt.btp_settings) { + curt.btp_settings = {}; + } + const previous_check_in_per_match = curt.btp_settings.check_in_per_match; + const btp_settings = c.val.btp_settings; + for (const [key, value] of Object.entries(btp_settings)) { + curt.btp_settings[key] = value; + } + ctournament.update_btp_settings_ui(); + if (previous_check_in_per_match !== curt.btp_settings.check_in_per_match) { + ctournament.refresh_current_view(); + } + break; + case 'update_btp_scoring_formats': + if(!curt.scoring_formats) { + curt.scoring_formats = {}; + } + const scoring_formats = c.val.scoring_formats; + for (const [key, value] of Object.entries(scoring_formats)) { + curt.scoring_formats[key] = value; + } + ctournament.close_scoring_format_dialog_if_open(undefined, 'tournament:edit:scoring_formats:dialog_closed_btp_sync'); + ctournament.update_scoring_formats(); + break; + case 'scoring_format_changed': + if (!curt.scoring_formats) { + curt.scoring_formats = { formats: [], default_id: null }; + } + if (!Array.isArray(curt.scoring_formats.formats)) { + curt.scoring_formats.formats = []; + } + const changed_scoring_format = c.val.scoring_format; + const changed_index = curt.scoring_formats.formats.findIndex(f => Number(f.id) === Number(changed_scoring_format.id)); + if (changed_index === -1) { + curt.scoring_formats.formats.push(changed_scoring_format); + } else { + curt.scoring_formats.formats[changed_index] = changed_scoring_format; + } + ctournament.close_scoring_format_dialog_if_open(changed_scoring_format.id, 'tournament:edit:scoring_formats:dialog_closed_external_change'); + ctournament.update_scoring_formats(); + ctournament.update_stages_scoring_formats(); + break; + case 'update_btp_events': + if(!curt.events) { + curt.events = {}; + } + const events = c.val.events; + for (const [key, value] of Object.entries(events)) { + curt.events[key] = value; + } + ctournament.update_stages_scoring_formats(); + break; + case 'update_display_setting': + const updated_setting = c.val.setting; + const index = curt.displaysettings.findIndex(m => m.id === updated_setting.id); + if (index === -1) { + curt.displaysettings.push(updated_setting); + curt.displaysettings.sort(utils.cmp_key('id')); + } else { + curt.displaysettings[index] = updated_setting; + } + ctournament.update_general_displaysettings(c); + break; + case 'delete_display_setting': + const removed_setting_id = c.val.setting_id; + const rs = utils.find(curt.displaysettings, m => m.id === removed_setting_id); + curt.displaysettings.splice(curt.displaysettings.indexOf(rs), 1); + ctournament.update_general_displaysettings(c); + break; + case 'display_status_changed': + const display_setting = c.val.display_court_displaysetting; + var d = utils.find(curt.displays, m => m.client_id === display_setting.client_id); + const last_d = {...d}; + if (!d) { + curt.displays[curt.displays.length] = display_setting; + curt.displays.sort(utils.cmp_key('client_id')); + return; + } else { + d.court_id = display_setting.court_id; + d.displaysetting_id = display_setting.displaysetting_id; + d.online = display_setting.online; + if(display_setting.battery){ + d.battery = display_setting.battery; + } + + if(d.displaysetting_id != last_d.displaysetting_id){ + ctournament.update_general_displaysettings(c); + } + } + if (last_d.online != d.online) { + cerror.silent('Display ' + display_setting.client_id + ' is ' + (display_setting.online ? 'online' : 'offline')); + } + if(!utils.deep_equal(d, last_d)){ + ctournament.update_display(d); + } + break; + case 'delete_display': + const client_id = c.val.client_id; + const display = utils.find(curt.displays, m => m.client_id === client_id); + utils.remove(curt.displays, display); + ctournament.delete_display(c); + break; + case 'display_wait_for_done': + var d = utils.find(curt.displays, m => m.client_id === c.val.client_id); + d.wait_for_ctype = c.val.ctype; + if(!d.wait_for_done) { + d.wait_for_done = true; + ctournament.update_display(d); + } + break; + case 'display_is_done': + var d = utils.find(curt.displays, m => m.client_id === c.val.client_id); + if(d.wait_for_done && d.wait_for_ctype == c.val.ctype) { + d.wait_for_done = false; + ctournament.update_display(d); + } + break; + default: + cerror.silent('Unsupported change type ' + c.ctype); + } } -} -return { - default_handler, - change_current_match, -}; + return { + default_handler, + _apply_umpires_changed: apply_umpires_changed + }; })(); @@ -146,9 +656,11 @@ return { if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var cerror = require('./cerror'); var cmatch = require('./cmatch'); + var ctournament = require('./ctournament'); var uiu = require('../bup/js/uiu'); var utils = require('./utils'); - + var cumpires = require('./cumpires'); + var change_helpers = require('./change_helpers'); module.exports = change; } /*/@DEV*/ diff --git a/static/js/change_helpers.js b/static/js/change_helpers.js new file mode 100644 index 0000000..236bb0d --- /dev/null +++ b/static/js/change_helpers.js @@ -0,0 +1,31 @@ +'use strict'; + +var change_helpers = (function() { + function apply_umpires_changed(update, deps) { + const { + curt_ref, + uiu_ref, + cmatch_ref, + current_view_ref, + cumpires_ref, + ctournament_ref + } = deps; + + curt_ref.umpires = update.all_umpires; + uiu_ref.qsEach('select[name="umpire_name"]', function(select) { + cmatch_ref.render_umpire_options(select, select.value); + }); + if(current_view_ref === 'show') { + cumpires_ref.ui_status(uiu_ref.qs('.umpire_container')); + } + ctournament_ref.update_officials(); + } + + return { + apply_umpires_changed + }; +})(); + +if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { + module.exports = change_helpers; +} diff --git a/static/js/ci18n.js b/static/js/ci18n.js index f59833a..40b827a 100644 --- a/static/js/ci18n.js +++ b/static/js/ci18n.js @@ -33,6 +33,7 @@ function detect_lang() { function register_all() { register_lang(ci18n_en); register_lang(ci18n_de); + register_lang(ci18n_nl); } function init() { @@ -108,6 +109,7 @@ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var utils = require('../bup/dev/js/utils'); var ci18n_de = require('./ci18n_de'); var ci18n_en = require('./ci18n_en'); + var ci18n_en = require('./ci18n_nl'); ci18n.register_all(); module.exports = ci18n; diff --git a/static/js/ci18n_de.js b/static/js/ci18n_de.js index 2df4b56..9bf0a64 100644 --- a/static/js/ci18n_de.js +++ b/static/js/ci18n_de.js @@ -1,105 +1,389 @@ var ci18n_de = { -'_code': 'de', -'_name': 'Deutsch (Deutschland)', + '_code': 'de', + '_name': 'Deutsch (Deutschland)', -'Unassigned Matches': 'Nicht zugewiesene Spiele', -'Next Matches': 'Nächste Spiele', -'edit tournament': 'Turnier bearbeiten', -'Court': 'Court', -'Match': 'Spiel', -'Players': 'Spieler', -'Umpire': 'Schiedsrichter', -'State': 'Status', -'Umpire:': 'Schiedsrichter:', -'Service judge:': 'Aufschlagrichter:', -'Finished Matches': 'Abgeschlossene Spiele', -'Time:': 'Zeit:', -'(Singles)': '(Einzel)', -'e.g. MX O55': 'z.B. MX O55', -'e.g. semi-finals': 'z.B. Halbfinale', -'Number:': '#:', -'Cancel': 'Abbrechen', -'Change': 'Ändern', -'No umpire': 'Kein Schiedsrichter', -'No service judge': 'Kein Aufschlagrichter', -'Edit match': 'Match bearbeiten', -'Back': 'Zurück', -'PDF': 'PDF', -'Print': 'Drucken', -'Add Match': 'Spiel hinzufügen', -' Ready to start ': ' Spielbereit ', -'Ready': 'Bereit', -'Not assigned': 'Nicht zugewiesen', -'team competition': 'Mannschafts-Wettbewerb', -'nation competition': 'Internationales Turnier', -'update from BTP': 'Von BTP aktualisieren', -'update ticker': 'Ticker aktualisieren', -'Tournaments': 'Turniere', -'referee view': 'Referee-Ansicht', -'Connecting ...': 'Verbinde ...', -'Connected': 'Verbunden', -'Connection lost': 'Verbindung verloren', -'Create tournament': 'Turnier erstellen', -'create:id:label': 'Turnier-ID (Kleinbuchstaben, keine Leerzeichen):', -'experimental': '(experimentell)', + 'Unassigned Matches': 'Nicht zugewiesene Spiele', + 'Next Matches': 'Nächste Spiele', + 'Current Matches': 'Laufende Spiele', + 'Matchoverview': 'Spielübersicht', + 'Self-Check-In': 'Self-Check-In', + 'Self-Check-In: ready': 'Bereit zum Aufruf', + 'Self-Check-In: waiting': 'Warten auf Check-In', + 'Self-Check-In: called': 'Aufgerufen', + 'Self-Check-In: empty': 'Aktuell sind keine Paarungen in Vorbereitung.', + 'Scoreboard': 'Anzeigetafel', + 'Umpire Panel': 'Schiedsrichter-Panel', + 'Service judge': 'Aufschlagrichter', + 'Tablet operator': 'Tabletbedienung', + 'Menu': 'Menü', + 'All Locations': 'Alle Locations', + 'only location': 'nur', + 'edit tournament': 'bearbeiten', + 'edit BTS settings': 'BTS-Einstellungen bearbeiten', + 'Court': 'Feld', + 'Match': 'Spiel', + 'Players': 'Spieler', + 'Umpire': 'Schiedsrichter', + 'State': 'Status', + 'Umpire:': 'Schiedsrichter:', + 'Technical officials rotation:': 'Rotation der technischen Offiziellen', + 'Service judge:': 'Aufschlagrichter:', + 'Finished Matches': 'Abgeschlossene Spiele', + 'Time:': 'Zeit:', + '(Singles)': '(Einzel)', + 'e.g. MX O55': 'z.B. MX O55', + 'e.g. semi-finals': 'z.B. Halbfinale', + 'Number:': '#:', + 'Cancel': 'Abbrechen', + 'Change': 'Ändern', + 'Confirm_Finish': 'Spielende bestätigen', + 'No umpire': 'Kein Schiedsrichter', + 'No service judge': 'Kein Aufschlagrichter', + 'Waiting list umpire': 'Warteliste Schiedsrichter', + 'Waiting list service judge': 'Warteliste Aufschlagrichter', + 'Edit match': 'Match bearbeiten', + 'Edit display setting': 'Anzeigeeinstellung bearbeiten', + 'Back': 'Zurück', + 'PDF': 'PDF', + 'Print': 'Drucken', + 'Add Match': 'Spiel hinzufügen', + ' Ready to start ': ' Spielbereit ', + 'Ready': 'Bereit', + 'Not assigned': 'Nicht zugewiesen', + 'team competition': 'Mannschafts-Wettbewerb', + 'nation competition': 'Internationales Turnier', + 'update from BTP': 'aktualisieren', + 'update BTP data': 'BTP-Daten aktualisieren', + 'update ticker': 'aktualisieren', + 'Tournaments': 'Turniere', + 'referee view': 'Referee-Ansicht', + 'Connecting ...': 'Verbinde ...', + 'Connected': 'Verbunden', + 'Connection lost': 'Verbindung verloren', + 'Create tournament': 'Turnier erstellen', + 'create:id:label': 'Turnier-ID (Kleinbuchstaben, keine Leerzeichen):', + 'experimental': '(experimentell)', + 'Winner': 'Gewinner', + 'Loser': 'Verlierer', + 'activate_court': 'Spielfeld nutzen', + 'inactivate_court': 'Spielfeld sperren', + 'Waiting for the next game:': 'Wartet auf das nächste Spiel:', + 'Currently on break:': 'Aktuell in der Pause:', + 'On court:': 'Auf dem Feld:', + 'In preparation:': 'In Vorbereitung:', + 'Assigned to a match:': 'Einem Spiel zugewiesen:', + 'Assigned to matches in preparation:': 'Für Spiele in Vorbereitung zugewiesen:', + 'Not available:': 'Nicht verfügbar:', -'tournament:edit:id': 'Turnier-ID:', -'tournament:edit:language': 'Sprache:', -'tournament:edit:language:auto': 'Nicht gesetzt (Browser-Einstellung)', -'tournament:edit:name': 'Name:', -'tournament:edit:courts': 'Felder:', -'tournament:edit:dm_style': 'Standard-Ansicht:', -'tournament:edit:only_now_on_court': 'Spiele müssen auf Feld gezogen werden', -'tournament:edit:btp:enabled': 'BTP-Anbindung aktivieren', -'tournament:edit:btp:autofetch_enabled': 'Automatisch synchronisieren', -'tournament:edit:btp:readonly': 'Nur lesen', -'tournament:edit:btp:ip': 'IP:', -'tournament:edit:btp:password': 'BTP-Passwort:', -'tournament:edit:btp:timezone': 'BTP-Zeitzone:', -'tournament:edit:btp:system timezone': 'Systemeinstellung ({tz})', -'tournament:edit:ticker_enabled': 'Ticker aktivieren', -'tournament:edit:ticker_url': 'Ticker-Adresse:', -'tournament:edit:ticker_password': 'Ticker-Passwort:', + 'announcements:begin_to_play': 'Bitte mit dem Spielen beginnen!', + 'announcements:second_call': 'Zweiter Aufruf', + 'announcements:second_call_for': 'für', + + 'announcements:vs': ' gegen ', + 'announcements:counting_board_service': 'Klapptafelbedienung:', + 'announcements:table_service': 'Tabletbedienung:', + 'announcements:please_as_tablet_service': 'bitte als Tabletbedienung', + 'announcements:umpire': 'Schiedsrichter:', + 'announcements:service_judge': 'Aufschlagrichter:', + 'announcements:and': ' und ', + 'announcements:preparation': 'In Vorbereitung', + 'announcements:meetingpoint': 'Treffen am Meetingpoint!', + 'announcements:on_court': 'Auf Spielfeld ', + 'announcements:for_court': 'Für Spielfeld ', + 'announcements:match_number': 'Spiel Nummer ', + 'announcements:boys_singles': 'Jungeneinzel', + 'announcements:boys_doubles': 'Jungendoppel', + 'announcements:girls_singles': 'Maedchenneinzel', + 'announcements:girls_doubles': 'Maedchendoppel', + 'announcements:mixed_doubles': 'Gemischtes Doppel', + 'announcements:men_singles': 'Herreneinzel', + 'announcements:men_doubles': 'Herrendoppel', + 'announcements:women_singles': 'Dameneinzel', + 'announcements:women_doubles': 'Damendoppel', + 'announcements:round_16': 'Achtelfinale', + 'announcements:quaterfinal': 'Viertelfinale', + 'announcements:semifinal': 'Halbfinale', + 'announcements:final': 'Finale', + 'announcements:intermediate_round': 'Zwischenrunde', + 'announcements:round_for_places': 'Runde um Platz', + 'announcements:to': 'bis', + 'announcements:game_for_place': 'Spiel um Platz', + 'announcements:voice': 'Google Deutsch', + 'announcements:lang': 'de-DE', + 'announcements:speechcheck:label': 'Sprachausgabe:', + 'announcements:speechcheck:button': 'Sprachtest', + 'announcements:speechcheck:untested': 'noch nicht getestet', + 'announcements:speechcheck:running': 'Test läuft ...', + 'announcements:speechcheck:ok': 'funktioniert', + 'announcements:speechcheck:error': 'fehlgeschlagen oder blockiert', + 'announcements:speechcheck:suspicious': 'verdächtig schnell beendet, vermutlich blockiert', + 'announcements:speechcheck:timeout': 'keine Rückmeldung vom Browser', + 'announcements:speechcheck:unsupported': 'vom Browser nicht unterstützt', + 'announcements:speechcheck:text': 'Dies ist ein Test der Sprachausgabe.', + 'tournament:edit:add': 'Hinzufügen', + 'tournament:edit:delete':'Löschen', -'to_stats:header': 'Statistik der Technischen Offiziellen', -'to_stats:name': 'Name', -'to_stats:umpire': 'SR', -'to_stats:service_judge': 'AR', -'to_stats:total': 'Total', + 'display_setting:id': 'ID: ', + 'display_setting:devicemode': 'Betriebsmodus: ', + 'display_setting:style': 'Erscheinungsbild: ', + 'display_setting:show_pause': 'Zeige verbleibende Pausenzeit', + 'display_setting:show_court_number': 'Zeige Spielfeldnummer', + 'display_setting:show_competition': 'Zeige die Konkurenz', + 'display_setting:show_round': 'Zeige die Runde', + 'display_setting:show_middle_name': 'Zeige den zweiten Vornamen der Spieler', + 'display_setting:show_doubles_receiving': 'Unterstreiche den annehmenden Spieler im Doppel', + 'display_setting:colors': 'Farben: ', + 'display_setting:use_team_colors': 'Verwende Team Farben', + 'display_setting:scale': 'Skalierung: ', + 'display_setting:language': 'Sprache: ', + 'display_setting:language_automatic': 'Automatic', + 'display_setting:language_en' : 'English', + 'display_setting:language_de' : 'Deutsch (Deutschland)', + 'display_setting:language_de-AT' : 'Deutsch (Österreich)', + 'display_setting:language_de-CH' : 'Deutsch (Schweiz)', + 'display_setting:language_fr-CH' : 'Français (Suisse)', + 'display_setting:language_nl-BE' : 'Vlaams', + 'display_setting:fullscreen_ask' : 'Vollbildmodus anfragen: ', + 'display_setting:show_announcements': 'Ansagen zeigen: ', + 'display_setting:autohide': 'Verberge Einstellungen nach (ms): ', + 'display_setting:click_mode': 'Touch-Erkennung: ', + 'display_setting:double_click_timeout': 'Doppel-Touch-Sperre (ms): ', + 'display_setting:button_block_timeout': 'Doppel-Touch-Sperre (ms): ', + 'display_setting:negative_timers': 'Stoppuhr läuft bis zum nächsten Punkt (auch negative Werte anzeigen)', + 'display_setting:shuttle_counter': 'Federballzähler anzeigen', + 'display_setting:editmode_doubleclick': 'Manuelles bearbeiten mit Doppelklick auf Feld', + 'display_setting:settings_style': 'Oberfläche: ', + 'display_setting:network_timeout': 'Netzwerk-Timeout (ms): ', + 'display_setting:network_update_interval': 'Netzwerk-Wiederholungszeit (ms): ', + 'display_setting:displaymode_update_interval': 'Displaymode Update Intervall (ms): ', + -'match:rawinfo': 'Technische Informationen', -'match:scoresheet': 'Schiedsrichterzettel', -'match:edit': 'Bearbeiten', -'match:incomplete': '[Unvollständig!] ', -'match:override_colors': 'Spezielle Farben', -'match:edit:scheduled_date': 'Datum:', -'match:edit:delete': 'Löschen', -'match:edit:now_on_court': 'Jetzt auf dem Feld', -'match:delete:really': 'Wirklich Spiel {match_id} löschen?', -'tournament:edit:logo': 'Logo', -'tournament:edit:logo:nologo': 'Kein Logo', -'tournament:edit:logo:upload': 'Logo hochladen', -'tournament:edit:logo:background': 'Logo-Hintergrund:', -'tournament:edit:logo:foreground': 'Farbe für Text auf Logo:', + 'tournament:edit:tournament': 'Turnier', + 'tournament:edit:tournament_flow': 'Turnier-Ablauf', + 'tournament:edit:ticker_connection': 'Ticker-Verbindung', + 'tournament:edit:btp_connection': 'BTP-Verbindung', + 'tournament:edit:devices': 'Verbundene Geräte', + 'tournament:edit:calls': 'Aufrufe', + 'tournament:edit:location': 'Austragungsort', + 'tournament:edit': 'Einstellungen Verwalten', + 'tournament:edit:save': 'Speichern', + 'tournament:edit:save_and_back': 'Speichern und zur Turnierübersicht', + 'tournament:edit:live_status:saving': 'Speichert ...', + 'tournament:edit:live_status:saved': 'Alle Änderungen gespeichert', + 'tournament:edit:live_status:error': 'Speichern fehlgeschlagen', -'nationstats': 'Nationen-Statistiken', -'nationstats:summary': '{player_count} Spieler aus {nation_count} verschiedenen Ländern', -'nationstats:summary:umpires': '{umpire_count} Schiedsrichter aus {nation_count} verschiedenen Ländern', -'umpires:status:heading': 'Schiedsrichter', -'umpires:status:ready': 'Bereit', -'umpires:status:paused': 'In Pause', -'umpires:paused_since': 'seit {time}', -'umpires:btp_id': 'BTP-ID {btp_id}', -'umpires:last_on_court': 'letztes Spiel endete {time}', + 'tournament:edit:tournament:type': 'Turnier-Typ:', + 'tournament:edit:id': 'Turnier-ID:', + 'tournament:edit:language': 'Sprache:', + 'tournament:edit:language:auto': 'Nicht gesetzt (Browser-Einstellung)', + 'tournament:edit:name': 'Name:', + 'tournament:edit:tguid': 'Turnier Guid:', + 'tournament:edit:courts': 'Felder:', + 'tournament:edit:dm_style': 'Standard-Ansicht:', + 'tournament:edit:displaysettings_general': 'Standard-Displayeinstellung:', + 'tournament:edit:warmup_timer_behavior': 'Verhalten des Vorbereitungs-Countdowns:', + 'tournament:edit:warmup_timer_behavior:bwf-2016': 'BWF ab 2016 (ab Auslosung)', + 'tournament:edit:warmup_timer_behavior:legacy': 'Deutschland (ab Auslosung)', + 'tournament:edit:warmup_timer_behavior:choise': 'ab Auslosung (individuelle Zeit)', + 'tournament:edit:warmup_timer_behavior:call-down': 'ab Aufruf (Countdown)', + 'tournament:edit:warmup_timer_behavior:call-up': 'ab Aufruf (Timer)', + 'tournament:edit:warmup_timer_behavior:none': 'Sofort beginen', + 'tournament:edit:warmup_ready': 'Spielbereit in Sekunden:', + 'tournament:edit:warmup_start': 'Spielstart nach Sekunden:', + 'tournament:edit:btp:enabled': 'BTP-Anbindung aktivieren', + 'tournament:edit:btp:autofetch_enabled': 'Automatisch synchronisieren', + 'tournament:edit:btp_autofetch_timeout_intervall': 'Aktualisierungsintervall (ms)', + 'tournament:edit:btp:readonly': 'Nur lesen', + 'tournament:edit:btp:ip': 'IP:', + 'tournament:edit:btp:password': 'BTP-Passwort:', + 'tournament:edit:btp:timezone': 'BTP-Zeitzone:', + 'tournament:edit:btp:system timezone': 'Systemeinstellung ({tz})', + 'tournament:edit:tabletoperator_enabled': 'Tabletbediener einsetzen', + 'tournament:edit:tabletoperator_winner_of_quaterfinals_enabled': 'Gewinner der Viertelfinals bedienen das Tablet', + 'tournament:edit:tabletoperator_split_doubles': 'Doppelpaarungen für Tabletbdienung teilen', + 'tournament:edit:tabletoperator_with_state_enabled': 'Landesverband anstelle Spieler aufrufen', + 'tournament:edit:tabletoperator_with_state_from_match_enabled': 'Landesverband des ersten Spielers im Match aufrufen', + 'tournament:edit:tabletoperator_set_break_after_tabletservice': 'Pausenzeit für Tabletbedienung setzen', + 'tournament:edit:tabletoperator_break_seconds': 'Pausenzeit nach Tabletbedienung (sec)', + 'tournament:edit:tabletoperator_with_umpire_enabled': 'Schiedrichter und Tabletbediener aufrufen', + 'tournament:edit:tabletoperator_use_manual_counting_boards_enabled': 'Klapptafeln anstelle von Tablets in Nutzung', + 'tournament:edit:official_rotation_mode': 'Rotation der technischen Offiziellen:', + 'tournament:edit:official_rotation_mode:disabled': 'deaktiviert', + 'tournament:edit:official_rotation_mode:umpire_only': 'nur Schiedsrichterrotation', + 'tournament:edit:official_rotation_mode:umpire_and_service_judge': 'Schieds- und Aufschlagrichterrotation', + 'tournament:edit:technical_official_auto_assignment_mode': 'Automatisches Ansetzen:', + 'tournament:edit:technical_official_auto_assignment_mode:manual_only': 'nur manuell', + 'tournament:edit:technical_official_auto_assignment_mode:on_match_call_if_possible': 'beim Spielaufruf, wenn auf dem Feld möglich', + 'tournament:edit:technical_official_auto_assignment_mode:on_preparation_call': 'beim Aufruf in Vorbereitung', + 'tournament:edit:technical_official_auto_assignment_mode:when_available': 'sobald technische Offizielle verfügbar sind', + 'tournament:edit:technical_official_break_after_assignment_seconds': 'Pause nach Einsatz (sek):', + 'tournament:edit:annoncement_include_event': 'Disziplin ansagen', + 'tournament:edit:annoncement_include_round': 'Turnierrunden ansagen', + 'tournament:edit:annoncement_include_matchnumber': 'Spielnummer ansagen', + 'tournament:edit:normalizations': 'Ausspracheoptimierung', + 'tournament:edit:normalizations:origin': 'Original', + 'tournament:edit:normalizations:replace': 'Ersetzung', + 'tournament:edit:normalizations:language': 'Sprache', + 'tournament:edit:advertisements': 'Werbung', + 'tournament:edit:advertisements:id': 'Id', + 'tournament:edit:advertisements:url': 'URL', + 'tournament:edit:advertisements:type': 'Typ', + 'tournament:edit:advertisements:disabled': 'Deaktiviert', + 'tournament:edit:announcement_speed': 'Ansagegeschwindigkeit (0.8-1.3): ', + 'tournament:edit:announcement_pause_time_ms': 'Pause zwischen Ansagen (sek): ', + 'tournament:edit:preparation_meetingpoint_enabled': 'Meetingpoint für Vorbereitung nutzen', + 'tournament:edit:preparation_tabletoperator_setup_enabled': 'Tabletbediener in Vorbereitung aufrufen', + 'tournament:edit:ticker_enabled': 'Ticker aktivieren', + 'tournament:edit:ticker_url': 'Ticker-Adresse:', + 'tournament:edit:ticker_password': 'Ticker-Passwort:', + 'tournament:edit:general_displaysettings': 'Verwaltung der Displayeinstellungen:', + 'tournament:edit:displays': 'Anzeigen administrieren:', + 'tournament:edit:displays:hostname': 'Hostname', + 'tournament:edit:displays:batterylevel': 'Akku', + 'tournament:edit:displays:battery_charging_time': 'Lädt: {battery_charging_time} Minuten Ladzeit.', + 'tournament:edit:displays:battery_duscharging_time': '{battery_discharging_time} Minuten Restlaufzeit.', + 'tournament:edit:displays:num': 'Monitor', + 'tournament:edit:displays:court': 'Feld', + 'tournament:edit:displays:setting': 'Einstellung', + 'tournament:edit:displays:description': 'Beschreibung', + 'tournament:edit:displays:onlinestatus': 'Status', + 'tournament:edit:tablets': 'Tablets Einstellungen:', + 'tournament:edit:ticker': 'Ticker Einstellungen:', + 'tournament:edit:btp': 'Badminton Turnier Planer Einstellungen:', + 'tournament:edit:bts': 'Badminton Turnier Server Einstellungen:', + 'tournament:edit:upcoming_matches_settings': 'Spielübersichts Einstellungen', + 'tournament:edit:upcoming_matches_animation_speed': 'Animationsgeschwindigkeit beim Scrollen der Spielübersichten', + 'tournament:edit:upcoming_matches_animation_pause': 'Animationsunterbrechung am Anfang und Ende der Seite (sec)', + 'tournament:edit:upcoming_matches_max_count': 'Maximale Anzahl von Spielen in der Spielübersicht', + 'tournament:edit:self_check_in_called_overlay_duration_ms': 'Einblenddauer der aufgerufenen Self-Check-In-Kachel (sek)', + 'tournament:edit:call_preparation_matches_automatically_enabled': 'Automatik für Spiele in Vorbereitung aktivieren', + 'tournament:edit:call_next_possible_scheduled_match_in_preparation': 'Automatik für Spiele aufrufen aktivieren', + 'tournament:edit:call_on_court_participant_readiness_mode': 'Teilnehmer-Regel', + 'tournament:edit:call_on_court_participant_readiness_mode:value': 'Voraussetzung', + 'tournament:edit:option:call_on_court_participant_readiness_mode:disabled': 'deaktiviert', + 'tournament:edit:option:call_on_court_participant_readiness_mode:checked_in': 'Alle Spieler müssen eingecheckt sein', + 'tournament:edit:option:call_on_court_participant_readiness_mode:pause_expired': 'Pausenzeiten aller beteiligten Spieler müssen abgelaufen sein', + 'tournament:edit:call_on_court_technical_officials_mode': 'Technische Offizielle', + 'tournament:edit:call_on_court_technical_officials_mode:value': 'Voraussetzung', + 'tournament:edit:option:call_on_court_technical_officials_mode:disabled': 'deaktiviert', + 'tournament:edit:option:call_on_court_technical_officials_mode:checked_in': 'Die Offiziellen müssen eingecheckt sein', + 'tournament:edit:option:call_on_court_technical_officials_mode:available': 'Offizielle müssen verfügbar sein', + 'tournament:edit:call_on_court_technical_officials_mode:hint_rotation_disabled': 'Nur verfügbar, wenn die Rotation der technischen Offiziellen aktiv ist.', + 'tournament:edit:call_on_court_technical_officials_mode:hint_auto_assignment_mode': '„Offizielle müssen verfügbar sein“ ist nur verfügbar bei automatischem Ansetzen „sobald technische Offizielle verfügbar sind“, „beim Aufruf in Vorbereitung“ oder „beim Spielaufruf, wenn auf dem Feld möglich“.', + 'tournament:edit:call_on_court_require_official_space_enabled': 'Verwende ein Feld nur, wenn es Platz für die angesetzten Offiziellen gibt', + 'tournament:edit:call_on_court_only_preparation_enabled': 'Nur Spiele in Vorbereitung aufrufen', + 'tournament:edit:call_on_court_only_preparation_minutes': 'Mindestzeit in Vorbereitung', + 'tournament:edit:call_on_court_time_limit_before_scheduled_enabled': 'Zeitregel vor eigener Ansetzung aktivieren', + 'tournament:edit:call_on_court_time_limit_before_scheduled_minutes': 'Frühestens so viele Minuten vor der eigenen Ansetzung', + 'tournament:edit:call_on_court_block_ahead_limit_enabled': 'Blockregel aktivieren', + 'tournament:edit:call_on_court_block_ahead_limit': 'Maximal so viele Blöcke voraus', + 'tournament:edit:call_on_court_time_ahead_of_frontier_enabled': 'Zeitregel relativ zum ersten nicht nutzbaren Spiel aktivieren', + 'tournament:edit:call_on_court_time_ahead_of_frontier_minutes': 'Maximal so viele Minuten später als das erste nicht nutzbare Spiel', + 'tournament:edit:call_on_court_matches_ahead_of_frontier_enabled': 'Spielanzahl-Regel relativ zum ersten nicht nutzbaren Spiel aktivieren', + 'tournament:edit:call_on_court_matches_ahead_of_frontier_limit': 'Maximal so viele Spiele voraus', + 'tournament:edit:call_on_court_player_pause_expired_enabled': 'Pausenzeiten aller beteiligten Spieler müssen abgelaufen sein', + 'tournament:edit:preparation_successor_rally_count': 'Ballwechsel bis ein Nachfolger in Vorbereitung benötigt wird', + 'tournament:edit:preparation_call_time_limit_before_scheduled_enabled': 'Zeitregel vor eigener Ansetzung aktivieren', + 'tournament:edit:preparation_call_time_limit_before_scheduled_minutes': 'Frühestens so viele Minuten vor der eigenen Ansetzung', + 'tournament:edit:preparation_call_block_ahead_limit_enabled': 'Blockregel aktivieren', + 'tournament:edit:preparation_call_block_ahead_limit': 'Maximal so viele Blöcke voraus', + 'tournament:edit:preparation_call_time_ahead_of_frontier_enabled': 'Zeitregel relativ zum ersten nicht nutzbaren Spiel aktivieren', + 'tournament:edit:preparation_call_time_ahead_of_frontier_minutes': 'Maximal so viele Minuten später als das erste nicht nutzbare Spiel', + 'tournament:edit:preparation_call_matches_ahead_of_frontier_enabled': 'Spielanzahl-Regel relativ zum ersten nicht nutzbaren Spiel aktivieren', + 'tournament:edit:preparation_call_matches_ahead_of_frontier_limit': 'Maximal so viele Spiele voraus', + 'tournament:edit:preparation_call_player_pause_expired_enabled': 'Pausenzeiten aller beteiligten Spieler müssen abgelaufen sein', + 'tournament:edit:preparation_call_technical_officials_available_enabled': 'Technische Offizielle müssen verfügbar sein', + 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_rotation_disabled': 'Nur verfügbar, wenn die Rotation der technischen Offiziellen aktiv ist.', + 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_auto_assignment_mode': 'Nur verfügbar bei automatischem Ansetzen „sobald technische Offizielle verfügbar sind“ oder „beim Aufruf in Vorbereitung“.', + 'tournament:edit:minutes': 'Minuten', + 'tournament:edit:scoring_formats': 'Punktsysteme', + 'tournament:edit:scoring_formats:dialog_title': 'Punktsystem bearbeiten', + 'tournament:edit:scoring_formats:dialog_hint': 'Aus BTP importierte Felder sind schreibgeschützt. Bearbeitet werden nur lokale Timing-Werte.', + 'tournament:edit:scoring_formats:dialog_closed_external_change': 'Der Dialog wurde geschlossen, weil dieses Punktsystem in einem anderen Fenster geändert wurde.', + 'tournament:edit:scoring_formats:dialog_closed_btp_sync': 'Der Dialog wurde geschlossen, weil die Punktsysteme durch den BTP-Abgleich aktualisiert wurden.', + 'tournament:edit:scoring_formats:name': 'Name', + 'tournament:edit:scoring_formats:num_sets': 'Sätze', + 'tournament:edit:scoring_formats:regular_sets': 'Normale Sätze', + 'tournament:edit:scoring_formats:last_set': 'Letzter Satz', + 'tournament:edit:scoring_formats:type': 'Typ', + 'tournament:edit:scoring_formats:type_regular': 'normal:', + 'tournament:edit:scoring_formats:type_last': 'letzter:', + 'tournament:edit:scoring_formats:default': 'Standard', + 'tournament:edit:scoring_formats:default_badge': '★', + 'tournament:edit:scoring_formats:edit': 'Bearbeiten', + 'tournament:edit:scoring_formats:end_max': 'Ende / Max', + 'tournament:edit:scoring_formats:end_points_label': 'Satzende bei', + 'tournament:edit:scoring_formats:max_points': 'Maximalpunkte', + 'tournament:edit:scoring_formats:break_in_set_enabled': 'Pause im Satz aktiv', + 'tournament:edit:scoring_formats:interval_at': 'Pause im Satz bei', + 'tournament:edit:scoring_formats:interval_duration': 'Pause im Satz', + 'tournament:edit:scoring_formats:break_before_set': 'Pause vor Satz', + + + + 'to_stats:header': 'Statistik der Technischen Offiziellen', + 'to_stats:name': 'Name', + 'to_stats:umpire': 'SR', + 'to_stats:service_judge': 'AR', + 'to_stats:total': 'Total', -'csvexport:winners': 'CSV-Export für Siegerurkunden', + 'match:rawinfo': 'Technische Informationen', + 'match:manualcall': 'Aufruf: Manueller Spielaufruf', + 'match:preparationcall': 'Aufruf: Spiel in Vorbereitung', + 'match:begintoplay': 'Aufruf: Mit dem Spielen beginnen', + 'match:secondcallteamone': 'Aufruf: Zweiter Aufruf Team 1', + 'match:secondcallteamtwo': 'Aufruf: Zweiter Aufruf Team 2', + 'match:secondcallumpire': 'Aufruf: Zweiter Aufruf Schiedsrichter', + 'match:secondcallservicejudge': 'Aufruf: Zweiter Aufruf Aufschlagrichter', + 'match:secondcaltabletoperator': 'Aufruf: Zweiter Aufruf Tabletbediener', + 'match:scoresheet': 'Schiedsrichterzettel', + 'match:edit': 'Bearbeiten', + 'match:incomplete': '[Unvollständig!] ', + 'match:override_colors': 'Spezielle Farben', + 'match:edit:scheduled_date': 'Datum:', + 'match:edit:delete': 'Löschen', + 'match:edit:now_on_court': 'Jetzt auf dem Feld', + 'match:edit:error:service_judge_requires_umpire': 'Ein Aufschlagrichter kann nur gesetzt werden, wenn auch ein Schiedsrichter gesetzt ist.', + 'match:edit:show_all_officials': 'Alle Offiziellen anzeigen', + 'match:edit:preparation': 'In Vorbereitung:', + 'match:edit:not_in_preparation': 'nicht in Vorbereitung', + 'match:edit:in_preparation_for': 'in Vorbereitung für {location_name}', + 'match:edit:swap_hint': 'Tausch', + 'match:delete:really': 'Wirklich Spiel {match_id} löschen?', + 'match:add_umpire': 'Schiedsrichter hinzufügen', + 'match:add_service_judge': 'Aufschlagrichter hinzufügen', + 'tournament:edit:logo': 'Logo', + 'tournament:edit:logo:nologo': 'Kein Logo', + 'tournament:edit:logo:upload': 'Logo hochladen', + 'tournament:edit:logo:background': 'Logo-Hintergrund:', + 'tournament:edit:logo:foreground': 'Farbe für Text auf Logo:', + + 'nationstats': 'Nationen-Statistiken', + 'nationstats:summary': '{player_count} Spieler aus {nation_count} verschiedenen Ländern', + 'nationstats:summary:umpires': '{umpire_count} Schiedsrichter aus {nation_count} verschiedenen Ländern', + + 'umpires:status:heading': 'Schiedsrichter', + 'umpires:status:ready': 'Bereit', + 'umpires:status:paused': 'In Pause', + 'umpires:paused_since': 'seit {time}', + 'umpires:oncourt': 'Auf dem Feld', + 'umpires:btp_id': 'BTP-ID {btp_id}', + 'umpires:last_on_court': 'letztes Spiel endete {time}', + + 'tabletoperator:unassigned': 'Nächste TabletbedienerInnen', + 'tabletoperator:name': 'Name', + 'tabletoperator:add': 'Als Tabletoperator planen', + 'tabletoperator:move_up': 'In Liste vorziehen', + 'tabletoperator:move_down': 'In Liste zurückstellen', + 'tabletoperator:remove': 'Von Liste nehmen', + 'csvexport:winners': 'CSV-Export für Siegerurkunden', }; /*@DEV*/ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { module.exports = ci18n_de; } -/*/@DEV*/ \ No newline at end of file +/*/@DEV*/ diff --git a/static/js/ci18n_en.js b/static/js/ci18n_en.js index bd4e9fd..37cbb91 100644 --- a/static/js/ci18n_en.js +++ b/static/js/ci18n_en.js @@ -1,105 +1,384 @@ var ci18n_en = { -'_code': 'en', -'_name': 'English', + '_code': 'en', + '_name': 'English', -'Unassigned Matches': 'Unassigned Matches', -'Next Matches': 'Next Matches', -'edit tournament': 'edit tournament', -'Court': 'Court', -'Match': 'Match', -'Players': 'Players', -'Umpire': 'Umpire', -'State': 'State', -'Umpire:': 'Umpire:', -'Service judge:': 'Service judge:', -'Finished Matches': 'Finished Matches', -'Time:': 'Time:', -'(Singles)': '(Singles)', -'e.g. MX O55': 'e.g. MX O55', -'e.g. semi-finals': 'e.g. semi-finals', -'Number:': 'Number:', -'Cancel': 'Cancel', -'Change': 'Change', -'No umpire': 'No umpire', -'No service judge': 'No service judge', -'Edit match': 'Edit match', -'Back': 'Back', -'PDF': 'PDF', -'Print': 'Print', -'Add Match': 'Add Match', -' Ready to start ': ' Ready to start ', -'Ready': 'Ready', -'Not assigned': 'Not assigned', -'team competition': 'team competition', -'nation competition': 'international competition', -'update from BTP': 'update from BTP', -'update ticker': 'update ticker', -'Tournaments': 'Tournaments', -'referee view': 'Referee view', -'Connecting ...': 'Connecting ...', -'Connected': 'Connected', -'Connection lost': 'Connection lost', -'Create tournament': 'Create tournament', -'create:id:label': 'tournament ID (all lowercase, no spaces):', -'experimental': '(experimental)', + 'Unassigned Matches': 'Unassigned Matches', + 'Next Matches': 'Next Matches', + 'Current Matches': 'Current Matches', + 'Matchoverview': 'Matchoverview', + 'Self-Check-In': 'Self-Check-In', + 'Self-Check-In: ready': 'Ready for call', + 'Self-Check-In: waiting': 'Waiting for check-in', + 'Self-Check-In: called': 'Called', + 'Self-Check-In: empty': 'There are currently no matches in preparation.', + 'Scoreboard': 'Scoreboard', + 'Umpire Panel': 'Umpire Panel', + 'Service judge': 'Service judge', + 'Tablet operator': 'Tablet operator', + 'Menu': 'Menu', + 'All Locations': 'All Locations', + 'only location': 'only', + 'edit tournament': 'edit', + 'edit BTS settings': 'Edit BTS settings', + 'Court': 'Court', + 'Match': 'Match', + 'Players': 'Players', + 'Umpire': 'Umpire', + 'State': 'State', + 'Umpire:': 'Umpire:', + 'Technical officials rotation:': 'Technical officials rotation', + 'Service judge:': 'Service judge:', + 'Finished Matches': 'Finished Matches', + 'Time:': 'Time:', + '(Singles)': '(Singles)', + 'e.g. MX O55': 'e.g. MX O55', + 'e.g. semi-finals': 'e.g. semi-finals', + 'Number:': 'Number:', + 'Cancel': 'Cancel', + 'Change': 'Change', + 'Confirm_Finish': 'Confirm end of game', + 'No umpire': 'No umpire', + 'No service judge': 'No service judge', + 'Waiting list umpire': 'Waiting list umpire', + 'Waiting list service judge': 'Waiting list service judge', + 'Edit match': 'Edit match', + 'Edit display setting': 'Edit display setting', + 'Back': 'Back', + 'PDF': 'PDF', + 'Print': 'Print', + 'Add Match': 'Add Match', + ' Ready to start ': ' Ready to start ', + 'Ready': 'Ready', + 'Not assigned': 'Not assigned', + 'team competition': 'team competition', + 'nation competition': 'international competition', + 'update from BTP': 'update', + 'update BTP data': 'Update BTP data', + 'update ticker': 'update', + 'Tournaments': 'Tournaments', + 'referee view': 'Referee view', + 'Connecting ...': 'Connecting ...', + 'Connected': 'Connected', + 'Connection lost': 'Connection lost', + 'Create tournament': 'Create tournament', + 'create:id:label': 'tournament ID (all lowercase, no spaces):', + 'experimental': '(experimental)', + 'Winner': 'Winner', + 'Loser': 'Loser', + 'activate_court': 'Spielfeld nutzen', + 'inactivate_court': 'Spielfeld sperren', + 'Waiting for the next game:': 'Waiting for the next game:', + 'Currently on break:': 'Currently on break:', + 'On court:': 'On court:', + 'In preparation:': 'In preparation:', + 'Assigned to a match:': 'Assigned to a match:', + 'Assigned to matches in preparation:': 'Assigned to matches in preparation:', + 'Not available:': 'Not available:', -'tournament:edit:id': 'Tournament id:', -'tournament:edit:language': 'Language:', -'tournament:edit:language:auto': 'Not set (browser default)', -'tournament:edit:name': 'Name:', -'tournament:edit:courts': 'Courts:', -'tournament:edit:dm_style': 'Default display style:', -'tournament:edit:only_now_on_court': 'Matches have to be dragged onto court', -'tournament:edit:btp:enabled': 'Enable BTP synchronization', -'tournament:edit:btp:autofetch_enabled': 'Automatic synchronization', -'tournament:edit:btp:readonly': 'Read only', -'tournament:edit:btp:ip': 'IP address:', -'tournament:edit:btp:password': 'BTP password:', -'tournament:edit:btp:timezone': 'BTP timezone:', -'tournament:edit:btp:system timezone': 'System default ({tz})', + 'announcements:begin_to_play': 'Start to play!', + 'announcements:second_call': '"Second call', + 'announcements:second_call_for': 'for', + 'announcements:vs': ' vs ', + 'announcements:counting_board_service': 'Countingboard service:', + 'announcements:table_service': 'Tablet service:', + 'announcements:the_table_service_from': 'The Tablet service from', + 'announcements:please_as_tablet_service': 'please', + 'announcements:umpire': 'Umpire:', + 'announcements:service_judge': 'Servicejudge:', + 'announcements:and': ' and ', + 'announcements:preparation': 'In preparation', + 'announcements:meetingpoint': 'Come to the meetingpoint!', + 'announcements:on_court': 'On Court ', + 'announcements:for_court': 'For Court ', + 'announcements:match_number': 'Match number ', + 'announcements:boys_singles': 'Boys singles', + 'announcements:boys_doubles': 'Boys double', + 'announcements:girls_singles': 'Girls singles', + 'announcements:girls_doubles': 'Girls doubles', + 'announcements:mixed_doubles': 'Mixed doubles', + 'announcements:men_singles': 'Men singles', + 'announcements:men_doubles': 'Men doubles', + 'announcements:women_singles': 'Women singles', + 'announcements:women_doubles': 'Women doubles', + 'announcements:round_16': 'Round of 16', + 'announcements:quaterfinal': 'Quarterfinal', + 'announcements:semifinal': 'Semifinal', + 'announcements:final': 'Final', + 'announcements:intermediate_round': 'Intermediate round', + 'announcements:round_for_places': 'Round for places', + 'announcements:to': 'to', + 'announcements:game_for_place': 'Game for place ', + 'announcements:voice': 'Google UK English Male', + 'announcements:lang': 'en-EN', + 'announcements:speechcheck:label': 'Speech output:', + 'announcements:speechcheck:button': 'Speech test', + 'announcements:speechcheck:untested': 'not tested yet', + 'announcements:speechcheck:running': 'test running ...', + 'announcements:speechcheck:ok': 'working', + 'announcements:speechcheck:error': 'failed or blocked', + 'announcements:speechcheck:suspicious': 'ended suspiciously fast, probably blocked', + 'announcements:speechcheck:timeout': 'no browser response', + 'announcements:speechcheck:unsupported': 'not supported by browser', + 'announcements:speechcheck:text': 'This is a speech output test.', + 'tournament:edit:add': 'Add', + 'tournament:edit:delete': 'Delete', -'tournament:edit:ticker_enabled': 'Activate online ticker', -'tournament:edit:ticker_url': 'Ticker URL:', -'tournament:edit:ticker_password': 'Ticker password:', -'to_stats:header': 'Technical Officials Statistics', -'to_stats:name': 'Name', -'to_stats:umpire': 'U', -'to_stats:service_judge': 'SJ', -'to_stats:total': 'Total', + 'display_setting:id': 'ID:', + 'display_setting:wakelock': 'Operating mode: ', + 'display_setting:style': 'Style: ', + 'display_setting:show_pause': 'Show interval timer', + 'display_setting:show_court_number': 'Show court number', + 'display_setting:show_competition': 'Show the competition', + 'display_setting:show_round': 'Show the round', + 'display_setting:show_middle_name': 'Show middle names of players', + 'display_setting:show_doubles_receiving': 'Underline the receiving player in doubles', + 'display_setting:colors': 'Colors: : ', + 'display_setting:use_team_colors': 'Use team colors', + 'display_setting:scale': 'Scale: : ', + 'display_setting:language': 'Language: ', + 'display_setting:language_automatic': 'Automatic', + 'display_setting:language_en': 'English', + 'display_setting:language_de-DE': 'Deutsch (Deutschland)', + 'display_setting:language_de-AT': 'Deutsch (Österreich)', + 'display_setting:language_de-CH': 'Deutsch (Schweiz)', + 'display_setting:language_fr-CH': 'Français (Suisse)', + 'display_setting:language_nl-BE': 'Vlaams', + 'display_setting:fullscreen_ask': 'Request fullscrean on startup: ', + 'display_setting:show_announcements': 'Show announcements: ', + 'display_setting:autohide': 'Verberge Einstellungen nach (ms): ', + 'display_setting:double_click_timeout': 'Doppel-Touch-Sperre (ms): ', + 'display_setting:button_block_timeout': 'Double press protection(ms): ', + 'display_setting:negative_timers': 'Timers go into negative: ', + 'display_setting:shuttle_counter': 'Show shuttle counter: ', + 'display_setting:editmode_doubleclick': 'Touch-Erkennung: ', + 'display_setting:settings_style': 'User Interface: ', + 'display_setting:network_timeout': 'Network timeout (sm): ', + 'display_setting:network_update_interval': 'Network repeat interval (ms): ', + 'display_setting:displaymode_update_interval': 'Displaymode Update Intervall (ms): ', -'match:rawinfo': 'Technical information', -'match:scoresheet': 'Scoresheet', -'match:edit': 'Edit', -'match:override_colors': 'Override colors', -'match:incomplete': '[incomplete!] ', -'match:edit:scheduled_date': 'Date:', -'match:edit:delete': 'Delete match', -'match:edit:now_on_court': 'Now on court', -'match:delete:really': 'Really delete match {match_id}?', -'tournament:edit:logo': 'Logo', -'tournament:edit:logo:nologo': 'No logo', -'tournament:edit:logo:upload': 'Upload logo', -'tournament:edit:logo:background': 'Logo background:', -'tournament:edit:logo:foreground': 'Logo foreground:', + 'tournament:edit:tournament': 'Tournament', + 'tournament:edit:tournament_flow': 'Tournament flow', + 'tournament:edit:ticker_connection': 'Ticker connection', + 'tournament:edit:btp_connection': 'BTP connection', + 'tournament:edit:devices': 'Connected devices', + 'tournament:edit:calls': 'Calls', + 'tournament:edit:location': 'Location', + 'tournament:edit': 'Manage settings', + 'tournament:edit:save': 'Save', + 'tournament:edit:save_and_back': 'Save and back to the tournament overview', + 'tournament:edit:live_status:saving': 'Saving ...', + 'tournament:edit:live_status:saved': 'All changes saved', + 'tournament:edit:live_status:error': 'Saving failed', -'nationstats': 'Nation stats', -'nationstats:summary': '{player_count} players from {nation_count} nations', -'nationstats:summary:umpires': '{umpire_count} umpires from {nation_count} nations', + 'tournament:edit:tournament:type': 'Tournament type:', + 'tournament:edit:id': 'Tournament id:', + 'tournament:edit:language': 'Language:', + 'tournament:edit:language:auto': 'Not set (browser default)', + 'tournament:edit:name': 'Name:', + 'tournament:edit:tguid': 'Tournament Guid:', + 'tournament:edit:courts': 'Courts:', + 'tournament:edit:dm_style': 'Default display style:', + 'tournament:edit:displaysettings_general': 'Default displaysetting:', + 'tournament:edit:warmup_timer_behavior': 'Behaviour of the preparation countdown:', + 'tournament:edit:warmup_timer_behavior:bwf-2016': 'BWF 2016+ (after choice of side)', + 'tournament:edit:warmup_timer_behavior:legacy': 'legacy (after choice of side)', + 'tournament:edit:warmup_timer_behavior:choise': 'after choice of side (individual time)', + 'tournament:edit:warmup_timer_behavior:call-down': 'from call (countdown)', + 'tournament:edit:warmup_timer_behavior:call-up': 'from call (timer)', + 'tournament:edit:warmup_timer_behavior:none': 'none', + 'tournament:edit:warmup_ready': 'Ready in seconds:', + 'tournament:edit:warmup_start': 'Game starts after seconds:', + 'tournament:edit:btp:enabled': 'Enable BTP synchronization', + 'tournament:edit:btp:autofetch_enabled': 'Automatic synchronization', + 'tournament:edit:btp_autofetch_timeout_intervall': 'Synchronization intervall (ms)', + 'tournament:edit:btp:readonly': 'Read only', + 'tournament:edit:btp:ip': 'IP address:', + 'tournament:edit:btp:password': 'BTP password:', + 'tournament:edit:btp:timezone': 'BTP timezone:', + 'tournament:edit:btp:system timezone': 'System default ({tz})', -'umpires:status:heading': 'Umpires', -'umpires:status:ready': 'Ready', -'umpires:status:paused': 'Reserve', -'umpires:paused_since': 'since {time}', -'umpires:btp_id': 'BTP ID {btp_id}', -'umpires:last_on_court': 'previous match ended at {time}', + 'tournament:edit:ticker_enabled': 'Activate online ticker', + 'tournament:edit:ticker_url': 'Ticker URL:', + 'tournament:edit:ticker_password': 'Ticker password:', + 'tournament:edit:tabletoperator_enabled': 'Use Tabletoperators', + 'tournament:edit:tabletoperator_winner_of_quaterfinals_enabled': 'Winner of Quarterfinals have to do tabletoperatorservice', + 'tournament:edit:tabletoperator_set_break_after_tabletservice': 'Set break after tabletservice', + 'tournament:edit:tabletoperator_break_seconds': 'Pausenzeit nach Tabletbedienung (sec)', + 'tournament:edit:tabletoperator_split_doubles': 'Split Doubles for Tablet Service.', + 'tournament:edit:tabletoperator_with_state_enabled': 'Call up national association instead of player', + 'tournament:edit:tabletoperator_with_state_from_match_enabled': 'Call up national association of first player in match', + 'tournament:edit:tabletoperator_with_umpire_enabled': 'Announce Umpire and Tabletoperator ', + 'tournament:edit:tabletoperator_use_manual_counting_boards_enabled': 'Usage of Counting Boards instead of Tablets', + 'tournament:edit:official_rotation_mode': 'Technical officials rotation:', + 'tournament:edit:official_rotation_mode:disabled': 'disabled', + 'tournament:edit:official_rotation_mode:umpire_only': 'umpire rotation only', + 'tournament:edit:official_rotation_mode:umpire_and_service_judge': 'umpire and service judge rotation', + 'tournament:edit:technical_official_auto_assignment_mode': 'Automatic assignment:', + 'tournament:edit:technical_official_auto_assignment_mode:manual_only': 'manual only', + 'tournament:edit:technical_official_auto_assignment_mode:on_match_call_if_possible': 'on match call, if possible on court', + 'tournament:edit:technical_official_auto_assignment_mode:on_preparation_call': 'on preparation call', + 'tournament:edit:technical_official_auto_assignment_mode:when_available': 'as soon as technical officials are available', + 'tournament:edit:technical_official_break_after_assignment_seconds': 'Break after assignment (sec):', + 'tournament:edit:annoncement_include_event': 'Announce event', + 'tournament:edit:annoncement_include_round': 'Announce round of tournament', + 'tournament:edit:annoncement_include_matchnumber': 'Announce number of match', + 'tournament:edit:announcement_speed': 'Announcementspeed (0.8-1.3): ', + 'tournament:edit:announcement_pause_time_ms': 'Pause between announcements (sec): ', + 'tournament:edit:preparation_meetingpoint_enabled': 'Use Meetingpoint for preparation', + 'tournament:edit:preparation_tabletoperator_setup_enabled': 'Call up tablet operator in preparation', + 'tournament:edit:normalizations': 'Pronunciation optimization', + 'tournament:edit:normalizations:origin': 'Origin', + 'tournament:edit:normalizations:replace': 'Replacement', + 'tournament:edit:normalizations:language': 'Language', + 'tournament:edit:advertisements': 'Advertisements', + 'tournament:edit:advertisements:id': 'Id', + 'tournament:edit:advertisements:url': 'URL', + 'tournament:edit:advertisements:type': 'Type', + 'tournament:edit:advertisements:disabled': 'Disabled', + 'tournament:edit:general_displaysettings': 'Manaage Display Settings:', + 'tournament:edit:displays': 'Manage Displays:', + 'tournament:edit:displays:hostname': 'Hostname', + 'tournament:edit:displays:batterylevel': 'Battery', + 'tournament:edit:displays:battery_charging_time': 'Charging: {battery_charging_time} minutes remaining.', + 'tournament:edit:displays:battery_duscharging_time': '{battery_discharging_time} minutes left.', + 'tournament:edit:displays:num': 'Displaynumber', + 'tournament:edit:displays:court': 'Ar Court', + 'tournament:edit:displays:setting': 'Setting', + 'tournament:edit:displays:description': 'Description', + 'tournament:edit:displays:onlinestatus': 'Status', + 'tournament:edit:tablets': 'Tablets Settings:', + 'tournament:edit:ticker': 'Ticker Settings:', + 'tournament:edit:btp': 'Badminton Tournament Planer Settings:', + 'tournament:edit:bts': 'Badminton Tournament Server Settings:', + 'tournament:edit:upcoming_matches_settings': 'Game Overview Settings', + 'tournament:edit:upcoming_matches_animation_speed': 'Animationspeed for scroll on game overviews', + 'tournament:edit:upcoming_matches_animation_pause': 'Animation interruption at the beginning and end of the page (sec)', + 'tournament:edit:upcoming_matches_max_count': 'Maximum number of games in the game overview', + 'tournament:edit:self_check_in_called_overlay_duration_ms': 'Display duration of the called self check-in card (sec)', + 'tournament:edit:call_preparation_matches_automatically_enabled': 'Enable automation for matches in preparation', + 'tournament:edit:call_next_possible_scheduled_match_in_preparation': 'Enable automation for calling matches', + 'tournament:edit:call_on_court_participant_readiness_mode': 'Participant rule', + 'tournament:edit:call_on_court_participant_readiness_mode:value': 'Requirement', + 'tournament:edit:option:call_on_court_participant_readiness_mode:disabled': 'disabled', + 'tournament:edit:option:call_on_court_participant_readiness_mode:checked_in': 'All players must be checked in', + 'tournament:edit:option:call_on_court_participant_readiness_mode:pause_expired': 'All pause times of participating players must have expired', + 'tournament:edit:call_on_court_technical_officials_mode': 'Technical officials', + 'tournament:edit:call_on_court_technical_officials_mode:value': 'Requirement', + 'tournament:edit:option:call_on_court_technical_officials_mode:disabled': 'disabled', + 'tournament:edit:option:call_on_court_technical_officials_mode:checked_in': 'Officials must be checked in', + 'tournament:edit:option:call_on_court_technical_officials_mode:available': 'Officials must be available', + 'tournament:edit:call_on_court_technical_officials_mode:hint_rotation_disabled': 'Only available when the technical officials rotation is active.', + 'tournament:edit:call_on_court_technical_officials_mode:hint_auto_assignment_mode': '“Officials must be available” is only available with automatic assignment “as soon as technical officials are available”, “on preparation call”, or “on match call if possible on the court”.', + 'tournament:edit:call_on_court_require_official_space_enabled': 'Only use a court if there is room for the assigned officials', + 'tournament:edit:call_on_court_only_preparation_enabled': 'Only call matches already in preparation', + 'tournament:edit:call_on_court_only_preparation_minutes': 'Minimum time in preparation', + 'tournament:edit:call_on_court_time_limit_before_scheduled_enabled': 'Enable time rule before own scheduled time', + 'tournament:edit:call_on_court_time_limit_before_scheduled_minutes': 'At the earliest this many minutes before the own scheduled time', + 'tournament:edit:call_on_court_block_ahead_limit_enabled': 'Enable block rule', + 'tournament:edit:call_on_court_block_ahead_limit': 'At most this many blocks ahead', + 'tournament:edit:call_on_court_time_ahead_of_frontier_enabled': 'Enable time rule relative to the first unusable match', + 'tournament:edit:call_on_court_time_ahead_of_frontier_minutes': 'At most this many minutes later than the first unusable match', + 'tournament:edit:call_on_court_matches_ahead_of_frontier_enabled': 'Enable match-count rule relative to the first unusable match', + 'tournament:edit:call_on_court_matches_ahead_of_frontier_limit': 'At most this many matches ahead', + 'tournament:edit:call_on_court_player_pause_expired_enabled': 'All pause times of participating players must have expired', + 'tournament:edit:preparation_successor_rally_count': 'Rallies before a successor is needed in preparation', + 'tournament:edit:preparation_call_time_limit_before_scheduled_enabled': 'Enable time rule before own scheduled time', + 'tournament:edit:preparation_call_time_limit_before_scheduled_minutes': 'At the earliest this many minutes before the own scheduled time', + 'tournament:edit:preparation_call_block_ahead_limit_enabled': 'Enable block rule', + 'tournament:edit:preparation_call_block_ahead_limit': 'At most this many blocks ahead', + 'tournament:edit:preparation_call_time_ahead_of_frontier_enabled': 'Enable time rule relative to the first unusable match', + 'tournament:edit:preparation_call_time_ahead_of_frontier_minutes': 'At most this many minutes later than the first unusable match', + 'tournament:edit:preparation_call_matches_ahead_of_frontier_enabled': 'Enable match-count rule relative to the first unusable match', + 'tournament:edit:preparation_call_matches_ahead_of_frontier_limit': 'At most this many matches ahead', + 'tournament:edit:preparation_call_player_pause_expired_enabled': 'All pause times of participating players must have expired', + 'tournament:edit:preparation_call_technical_officials_available_enabled': 'Technical officials must be available', + 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_rotation_disabled': 'Only available when technical officials rotation is enabled.', + 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_auto_assignment_mode': 'Only available with automatic assignment set to “as soon as technical officials are available” or “on preparation call”.', + 'tournament:edit:minutes': 'minutes', + 'tournament:edit:scoring_formats': 'Scoring formats', + 'tournament:edit:scoring_formats:dialog_title': 'Edit scoring format', + 'tournament:edit:scoring_formats:dialog_hint': 'Fields imported from BTP are read-only. Only local timing values can be edited.', + 'tournament:edit:scoring_formats:dialog_closed_external_change': 'The dialog was closed because this scoring format was changed in another window.', + 'tournament:edit:scoring_formats:dialog_closed_btp_sync': 'The dialog was closed because the scoring formats were updated by the BTP sync.', + 'tournament:edit:scoring_formats:name': 'Name', + 'tournament:edit:scoring_formats:num_sets': 'Sets', + 'tournament:edit:scoring_formats:regular_sets': 'Regular sets', + 'tournament:edit:scoring_formats:last_set': 'Final set', + 'tournament:edit:scoring_formats:type': 'Type', + 'tournament:edit:scoring_formats:type_regular': 'regular:', + 'tournament:edit:scoring_formats:type_last': 'final:', + 'tournament:edit:scoring_formats:default': 'Default', + 'tournament:edit:scoring_formats:default_badge': '★', + 'tournament:edit:scoring_formats:edit': 'Edit', + 'tournament:edit:scoring_formats:end_max': 'Target / Max', + 'tournament:edit:scoring_formats:end_points_label': 'Set ends at', + 'tournament:edit:scoring_formats:max_points': 'Maximum points', + 'tournament:edit:scoring_formats:break_in_set_enabled': 'Break in set enabled', + 'tournament:edit:scoring_formats:interval_at': 'Break in set at', + 'tournament:edit:scoring_formats:interval_duration': 'Break in set', + 'tournament:edit:scoring_formats:break_before_set': 'Break before set', + 'to_stats:header': 'Technical Officials Statistics', + 'to_stats:name': 'Name', + 'to_stats:umpire': 'U', + 'to_stats:service_judge': 'SJ', + 'to_stats:total': 'Total', -'csvexport:winners': 'CSV export (winner\'s certificates)', + 'match:rawinfo': 'Technical information', + 'match:manualcall': 'Announcement: Manuall call of Match', + 'match:preparationcall': 'Announcement: Match in preparation', + 'match:begintoplay': 'Announcement: Beginn to play', + 'match:secondcallteamone': 'Announcement: Second call Team 1', + 'match:secondcallteamtwo': 'Announcement: Second call Team 2', + 'match:secondcallumpire': 'Announcement: Second call Umpire', + 'match:secondcallservicejudge': 'Announcement: Second call Servicejudge', + 'match:secondcaltabletoperator': 'Announcement: Second call Tabletoperator', + 'match:scoresheet': 'Scoresheet', + 'match:edit': 'Edit', + 'match:override_colors': 'Override colors', + 'match:incomplete': '[incomplete!] ', + 'match:edit:scheduled_date': 'Date:', + 'match:edit:delete': 'Delete match', + 'match:edit:now_on_court': 'Now on court', + 'match:edit:error:service_judge_requires_umpire': 'A service judge can only be assigned if an umpire is also assigned.', + 'match:edit:show_all_officials': 'Show all officials', + 'match:edit:preparation': 'In preparation:', + 'match:edit:not_in_preparation': 'not in preparation', + 'match:edit:in_preparation_for': 'in preparation for {location_name}', + 'match:edit:swap_hint': 'Swap', + 'match:delete:really': 'Really delete match {match_id}?', + 'match:add_umpire': 'Add umpire', + 'match:add_service_judge': 'Add service judge', + 'tournament:edit:logo': 'Logo', + 'tournament:edit:logo:nologo': 'No logo', + 'tournament:edit:logo:upload': 'Upload logo', + 'tournament:edit:logo:background': 'Logo background:', + 'tournament:edit:logo:foreground': 'Logo foreground:', + + 'nationstats': 'Nation stats', + 'nationstats:summary': '{player_count} players from {nation_count} nations', + 'nationstats:summary:umpires': '{umpire_count} umpires from {nation_count} nations', + + 'umpires:status:heading': 'Umpires', + 'umpires:status:ready': 'Ready', + 'umpires:status:paused': 'Reserve', + 'umpires:paused_since': 'since {time}', + 'umpires:oncourt': 'On Court', + 'umpires:btp_id': 'BTP ID {btp_id}', + 'umpires:last_on_court': 'previous match ended at {time}', + + 'tabletoperator:unassigned': 'Next Tabletoperators', + 'tabletoperator:name': 'Name', + 'tabletoperator:add': 'Schedule as Tabletoperator', + 'tabletoperator:move_up': 'Move up in list', + 'tabletoperator:move_down': 'Move down in list', + 'tabletoperator:remove': 'Remove from list', + 'csvexport:winners': 'CSV export (winner\'s certificates)', }; /*@DEV*/ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { module.exports = ci18n_en; } -/*/@DEV*/ \ No newline at end of file +/*/@DEV*/ diff --git a/static/js/ci18n_nl.js b/static/js/ci18n_nl.js new file mode 100644 index 0000000..0c65fdc --- /dev/null +++ b/static/js/ci18n_nl.js @@ -0,0 +1,268 @@ +var ci18n_nl = { + + '_code': 'nl', + '_name': 'Nederlands', + + 'Unassigned Matches': 'Niet Toegewezen Wedstrijden', + 'Next Matches': 'Volgende Wedstrijden', + 'Current Matches': 'Huidige Wedstrijden', + 'Matchoverview': 'Overzicht Wedstrijden', + 'Scoreboard': 'Scorebord', + 'Umpire Panel': 'Scheidsrechterpaneel', + 'edit tournament': 'wijzig toernooi', + 'Court': 'Baan', + 'Match': 'Wedstrijd', + 'Players': 'Spelers', + 'Umpire': 'Scheidsrechter', + 'State': 'Status', + 'Umpire:': 'Scheidsrechter:', + 'Service judge:': 'Service rechter:', + 'Finished Matches': 'Beëindigde Wedstrijden', + 'Time:': 'Tijd:', + '(Singles)': '(Enkelspel)', + 'e.g. MX O55': 'bijv. MX O55', + 'e.g. semi-finals': 'bijv. halve finales', + 'Number:': 'Nummer:', + 'Cancel': 'Annuleer', + 'Change': 'Muteer', + 'Confirm_Finish': 'Bevestig Einde Wedstrijd', + 'No umpire': 'Geen scheidsrechter', + 'No service judge': 'Geen servicerechter', + 'Edit match': 'Aanpassen wedstrijd', + 'Edit display setting': 'Aanpassen weergave instellingen', + 'Back': 'Terug', + 'PDF': 'PDF', + 'Print': 'Druk Af', + 'Add Match': 'Voeg Wedstrijd Toe', + ' Ready to start ': ' Klaar om te beginnen ', + 'Ready': 'Klaar', + 'Not assigned': 'Niet toegewezen', + 'team competition': 'competitie', + 'nation competition': 'landen toerooi', + 'update from BTP': 'Actualiseer BTP', + 'update ticker': 'Actualiseer Ticker', + 'Tournaments': 'Tournooien', + 'referee view': 'Referee weergave', + 'Connecting ...': 'Verbinden...', + 'Connected': 'Verbonden', + 'Connection lost': 'Verbinding verloren', + 'Create tournament': 'Creëer toernooi', + 'create:id:label': 'tournooi ID (allemaal kleine letterd, geen spaties):', + 'experimental': '(experimenteel)', + 'Winner': 'Winnaar', + 'Loser': 'Verliezer', + 'activate_court': 'Baan gebruiken', + 'inactivate_court': 'Baan blokkeren', + + 'announcements:begin_to_play': 'Speel!', + 'announcements:second_call': '"Tweede oproep voor:"', + 'announcements:vs': ' tegen ', + 'announcements:counting_board_service': 'Scorebordbediening:', + 'announcements:table_service': 'Tabletbediening:', + 'announcements:umpire': 'Scheidsrechter:', + 'announcements:service_judge': 'Servicerechter:', + 'announcements:and': ' en ', + 'announcements:preparation': 'In voorbereiding', + 'announcements:meetingpoint': 'Kom naar het meetingpoint!', + 'announcements:on_court': 'Op Baan ', + 'announcements:for_court': 'Voor Baan ', + 'announcements:match_number': 'Wedstrijdnummer ', + 'announcements:boys_singles': 'Jongens enkel', + 'announcements:boys_doubles': 'Jongens dubbel', + 'announcements:girls_singles': 'Meisjes enkel', + 'announcements:girls_doubles': 'Meisjes dubbel', + 'announcements:mixed_doubles': 'Gemengd dubbel', + 'announcements:men_singles': 'Mannen enkel', + 'announcements:men_doubles': 'Mannen dubbel', + 'announcements:women_singles': 'Dames enkel', + 'announcements:women_doubles': 'Dames dubbel', + 'announcements:round_16': 'Ronde van 16', + 'announcements:quaterfinal': 'Kwartfinale', + 'announcements:semifinal': 'Halve Finale', + 'announcements:final': 'Finale', + 'announcements:intermediate_round': 'Tussenronde', + 'announcements:round_for_places': 'Plaatsingsronde', + 'announcements:to': 'tot', + 'announcements:game_for_place': 'Wedstrijd om plaats ', + 'announcements:voice': 'Google Nederlands', + 'announcements:lang': 'nl-NL', + 'tournament:edit:add': 'Voeg Toe', + 'tournament:edit:delete': 'Verwijderen', + + + 'display_setting:id': 'ID:', + 'display_setting:wakelock': 'Bedrijfsmodus: ', + 'display_setting:style': 'Stijl: ', + 'display_setting:show_pause': 'Toon interval timer', + 'display_setting:show_court_number': 'Toon baannummer', + 'display_setting:show_competition': 'Toon de competitie', + 'display_setting:show_round': 'Toon de ronde', + 'display_setting:show_middle_name': 'Toon tussenvoegsel naam van spelers', + 'display_setting:show_doubles_receiving': 'Onderstreep de ontvangende speler in dubbels', + 'display_setting:colors': 'Kleuren : ', + 'display_setting:use_team_colors': 'Gebruik Teamkleuren', + 'display_setting:scale': 'Schaal: : ', + 'display_setting:language': 'Taal: ', + 'display_setting:language_automatic': 'Automatisch', + 'display_setting:language_en': 'Engels', + 'display_setting:language_de-DE': 'Duits (Duitsland)', + 'display_setting:language_de-AT': 'Duits (Oostenrijks)', + 'display_setting:language_de-CH': 'Duits (Zwitsers)', + 'display_setting:language_fr-CH': 'Frans (Zwitsers)', + 'display_setting:language_nl-BE': 'Vlaams', + 'display_setting:language_nl-NL': 'Nederlands', + 'display_setting:fullscreen_ask': 'Vraag volledig scherm bij opstarten: ', + 'display_setting:show_announcements': 'Toon aankondigingen: ', + 'display_setting:autohide': 'Verberg Instelling na (ms): ', + 'display_setting:double_click_timeout': 'Dubbelklik blokkade (ms): ', + 'display_setting:button_block_timeout': 'Dubbelklik bescherming (ms): ', + 'display_setting:negative_timers': 'Timers lopen door in negatief: ', + 'display_setting:shuttle_counter': 'Toon shuttleteller: ', +'display_setting:editmode_doubleclick': 'Touch-herkenning: ', + 'display_setting:settings_style': 'Gebruikersinterface: ', + 'display_setting:network_timeout': 'Netwerktimeout (s): ', + 'display_setting:network_update_interval': 'Netwerk-herhalingsinterval (ms): ', + 'display_setting:displaymode_update_interval': 'Schermmodus-update-interval (ms): ', + + 'tournament:edit:tournament': 'Toernooi', + 'tournament:edit:tournament_flow': 'Toernooiverloop', + 'tournament:edit:ticker_connection': 'Ticker-verbinding', + 'tournament:edit:btp_connection': 'BTP-verbinding', + 'tournament:edit:devices': 'Verbonden apparaten', + 'tournament:edit:calls': 'Oproepen', + 'tournament:edit:location': 'Locatie', + 'tournament:edit': 'Beheer instellingen', + 'tournament:edit:save': 'Opslaan', + 'tournament:edit:save_and_back': 'Opslaan en terug naar toernooioverzicht', + + 'tournament:edit:tournament:type': 'Type toernooi:', + 'tournament:edit:id': 'Toernooi-id:', + 'tournament:edit:language': 'Taal:', + 'tournament:edit:language:auto': 'Niet ingesteld (standaard browser)', + 'tournament:edit:name': 'Naam:', + 'tournament:edit:tguid': 'Toernooi-GUID:', + 'tournament:edit:courts': 'Banen:', + 'tournament:edit:dm_style': 'Standaard schermstijl:', + 'tournament:edit:displaysettings_general': 'Standaard scherminstelling:', + 'tournament:edit:warmup_timer_behavior': 'Gedrag voorbereidingstimer:', + 'tournament:edit:warmup_timer_behavior:bwf-2016': 'BWF 2016+ (na keuze speelhelft)', + 'tournament:edit:warmup_timer_behavior:legacy': 'Oud (na keuze speelhelft)', + 'tournament:edit:warmup_timer_behavior:choise': 'Na keuze speelhelft (individuele tijd)', + 'tournament:edit:warmup_timer_behavior:call-down': 'Vanaf oproep (aftellen)', + 'tournament:edit:warmup_timer_behavior:call-up': 'Vanaf oproep (timer)', + 'tournament:edit:warmup_timer_behavior:none': 'Geen', + 'tournament:edit:warmup_ready': 'Klaar in seconden:', + 'tournament:edit:warmup_start': 'Wedstrijd start na seconden:', + 'tournament:edit:btp:enabled': 'Activeer BTP-synchronisatie', + 'tournament:edit:btp:autofetch_enabled': 'Automatische synchronisatie', + 'tournament:edit:btp_autofetch_timeout_intervall': 'Synchronisatie-interval (ms)', + 'tournament:edit:btp:readonly': 'Alleen lezen', + 'tournament:edit:btp:ip': 'IP-adres:', + 'tournament:edit:btp:password': 'BTP-wachtwoord:', + 'tournament:edit:btp:timezone': 'BTP-tijdzone:', + 'tournament:edit:btp:system timezone': 'Standaard systeem ({tz})', + + 'tournament:edit:ticker_enabled': 'Activeer online ticker', + 'tournament:edit:ticker_url': 'Ticker-URL:', + 'tournament:edit:ticker_password': 'Ticker-wachtwoord:', + 'tournament:edit:tabletoperator_enabled': 'Gebruik Tabletgebruiker', + 'tournament:edit:tabletoperator_winner_of_quaterfinals_enabled': 'Winnaars kwartfinales moeten tabletbediening doen', + 'tournament:edit:tabletoperator_set_break_after_tabletservice': 'Pauze instellen na tabletbediening', + 'tournament:edit:tabletoperator_break_seconds': 'Pauzetijd na tabletgebruik (sec)', + 'tournament:edit:tabletoperator_split_doubles': 'Splits dubbels voor tabletbediening', + 'tournament:edit:tabletoperator_with_state_enabled': 'Roep bond/club op i.p.v. speler', + 'tournament:edit:tabletoperator_with_state_from_match_enabled': 'Roep bond/club op van eerste speler in wedstrijd', + 'tournament:edit:tabletoperator_with_umpire_enabled': 'Kondig scheidsrechter en tabletgebruiker aan', + 'tournament:edit:tabletoperator_use_manual_counting_boards_enabled': 'Gebruik scoreborden i.p.v. tablets', + 'tournament:edit:annoncement_include_event': 'Kondig onderdeel aan', + 'tournament:edit:annoncement_include_round': 'Kondig ronde van toernooi aan', + 'tournament:edit:annoncement_include_matchnumber': 'Kondig wedstrijdnummer aan', + 'tournament:edit:announcement_speed': 'Aankondigingssnelheid (0.8-1.3): ', + 'tournament:edit:announcement_pause_time_ms': 'Pauze tussen aankondigingen (sec): ', + 'tournament:edit:preparation_meetingpoint_enabled': 'Gebruik meetingpoint voor voorbereiding', + 'tournament:edit:preparation_tabletoperator_setup_enabled': 'Roep tabletgebruiker op in voorbereiding', + 'tournament:edit:normalizations': 'Uitspraak-optimalisatie', + 'tournament:edit:normalizations:origin': 'Origineel', + 'tournament:edit:normalizations:replace': 'Vervanging', + 'tournament:edit:normalizations:language': 'Taal', + 'tournament:edit:advertisements': 'Advertenties', + 'tournament:edit:advertisements:id': 'Id', + 'tournament:edit:advertisements:url': 'URL', + 'tournament:edit:advertisements:type': 'Type', + 'tournament:edit:advertisements:disabled': 'Uitgeschakeld', + 'tournament:edit:general_displaysettings': 'Beheer weergave-instellingen:', + 'tournament:edit:displays': 'Beheer schermen:', + 'tournament:edit:displays:hostname': 'Hostnaam', + 'tournament:edit:displays:batterylevel': 'Batterij', + 'tournament:edit:displays:battery_charging_time': 'Opladen: {battery_charging_time} minuten resterend.', + 'tournament:edit:displays:battery_duscharging_time': '{battery_discharging_time} minuten over.', + 'tournament:edit:displays:num': 'Schermnummer', + 'tournament:edit:displays:court': 'Baan', + 'tournament:edit:displays:setting': 'Instelling', + 'tournament:edit:displays:description': 'Omschrijving', + 'tournament:edit:displays:onlinestatus': 'Status', + 'tournament:edit:tablets': 'Tablet-instellingen:', + 'tournament:edit:ticker': 'Ticker-instellingen:', + 'tournament:edit:btp': 'Badminton Tournament Planner-instellingen:', + 'tournament:edit:bts': 'Badminton Tournament Server-instellingen:', + 'tournament:edit:upcoming_matches_settings': 'Instellingen wedstrijdoverzicht', + 'tournament:edit:upcoming_matches_animation_speed': 'Animatiesnelheid scrollen in wedstrijdoverzicht', + 'tournament:edit:upcoming_matches_animation_pause': 'Pauze begin/einde pagina (sec)', + 'tournament:edit:upcoming_matches_max_count': 'Max. aantal wedstrijden in overzicht', + 'tournament:edit:call_preparation_matches_automatically_enabled': 'Roep voorbereidende wedstrijden automatisch op vrije banen', + 'tournament:edit:call_next_possible_scheduled_match_in_preparation': 'Roep volgende mogelijke wedstrijd automatisch in voorbereiding op', + 'to_stats:header': 'Statistieken officials', + 'to_stats:name': 'Naam', + 'to_stats:umpire': 'U', + 'to_stats:service_judge': 'SJ', + 'to_stats:total': 'Totaal', + + 'match:rawinfo': 'Technische informatie', + 'match:manualcall': 'Aankondiging: handmatige oproep van wedstrijd', + 'match:preparationcall': 'Aankondiging: wedstrijd in voorbereiding', + 'match:begintoplay': 'Aankondiging: start wedstrijd', + 'match:secondcallteamone': 'Aankondiging: tweede oproep team 1', + 'match:secondcallteamtwo': 'Aankondiging: tweede oproep team 2', + 'match:secondcallumpire': 'Aankondiging: tweede oproep scheidsrechter', + 'match:secondcallservicejudge': 'Aankondiging: tweede oproep service judge', + 'match:secondcaltabletoperator': 'Aankondiging: tweede oproep tabletoperator', + 'match:scoresheet': 'Scoreformulier', + 'match:edit': 'Bewerken', + 'match:override_colors': 'Kleuren overschrijven', + 'match:incomplete': '[onvolledig!] ', + 'match:edit:scheduled_date': 'Datum:', + 'match:edit:delete': 'Wedstrijd verwijderen', + 'match:edit:now_on_court': 'Nu op baan', + 'match:delete:really': 'Wedstrijd {match_id} echt verwijderen?', + 'tournament:edit:logo': 'Logo', + 'tournament:edit:logo:nologo': 'Geen logo', + 'tournament:edit:logo:upload': 'Upload logo', + 'tournament:edit:logo:background': 'Achtergrond logo:', + 'tournament:edit:logo:foreground': 'Voorgrond logo:', + + 'nationstats': 'Landstatistieken', + 'nationstats:summary': '{player_count} spelers uit {nation_count} landen', + 'nationstats:summary:umpires': '{umpire_count} scheidsrechters uit {nation_count} landen', + + 'umpires:status:heading': 'Scheidsrechters', + 'umpires:status:ready': 'Beschikbaar', + 'umpires:status:paused': 'Reserve', + 'umpires:paused_since': 'sinds {time}', + 'umpires:oncourt': 'Op baan', + 'umpires:btp_id': 'BTP ID {btp_id}', + 'umpires:last_on_court': 'vorige wedstrijd eindigde om {time}', + + 'tabletoperator:unassigned': 'Volgende tabletgebruikers', + 'tabletoperator:name': 'Naam', + 'tabletoperator:add': 'Inplannen als tabletgebruiker', + 'tabletoperator:move_up': 'Omhoog in lijst', + 'tabletoperator:move_down': 'Omlaag in lijst', + 'tabletoperator:remove': 'Verwijderen uit lijst', + 'csvexport:winners': 'CSV-export (winnaarscertificaten)', +}; + +/*@DEV*/ +if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { + module.exports = ci18n_en; +} +/*/@DEV*/ \ No newline at end of file diff --git a/static/js/cmatch.js b/static/js/cmatch.js index c571276..2c962f8 100644 --- a/static/js/cmatch.js +++ b/static/js/cmatch.js @@ -2,6 +2,11 @@ var cmatch = (function() { +var has_resize_event = false; +var scroll_timer = setTimeout(auto_scroll, 4000); +var scroll_down = true; +let is_paused = false; + const OVERRIDE_COLORS_KEYS = ['', 'bg']; function calc_score_str(match) { @@ -16,24 +21,182 @@ function calc_section(m) { if (typeof m.team1_won === 'boolean') { return 'finished'; } - if (m.setup.court_id) { - if (!curt.only_now_on_court || m.setup.now_on_court) { - return 'court_' + m.setup.court_id; - } + if (m.setup.court_id && m.setup.now_on_court) { + return 'court_' + m.setup.court_id; } return 'unassigned'; } -function render_match_table_header(table, include_courts) { +function resolve_match_court(match, court) { + if (court) { + return court; + } + if (!match || !match.setup || !match.setup.court_id || !curt) { + return null; + } + return (curt.courts_by_id && curt.courts_by_id[match.setup.court_id]) + || utils.find(curt.courts || [], (c) => c._id == match.setup.court_id) + || null; +} + +function auto_scroll() { + if (is_paused) { + return; + } + + const scroll_speed = parseInt((curt && curt.upcoming_matches_animation_speed) ? curt.upcoming_matches_animation_speed : 2); + if (scroll_speed == 0) { + return; + } + + const scroll_object = document.querySelectorAll('.main_upcoming'); + let new_top = 0; + let height = 0; + let child_higth = 0; + + scroll_object.forEach((item) =>{ + + let old_top = 0; + if(item.style.top) { + old_top = parseInt(item.style.top); + } + + if(scroll_down) { + item.style.top = (old_top - scroll_speed)+'px'; + } else { + item.style.top = (old_top + scroll_speed)+'px'; + } + + new_top = parseInt(item.style.top); + + for (const child of item.children) { + child_higth += child.offsetHeight; + } + + height = item.offsetHeight; + }); + + if(new_top >= 0) { + scroll_down = true; + pause_scroll(); + } else if (height >= child_higth) { + scroll_down = false; + pause_scroll(); + } + + requestAnimationFrame(auto_scroll); // Verwendet eine gleichmäßige Animation +} +function pause_scroll() { + is_paused = true; + setTimeout(() => { + is_paused = false; + auto_scroll(); + }, parseInt((curt && curt.upcoming_matches_animation_pause) ? curt.upcoming_matches_animation_pause : 4) * 1000); +} + +function resize_table(resizable_rows, table_width_factor) { + resizable_rows.forEach((row) => { + row.fixed_width_elements.forEach((item, index) => { + auto_size(item, row.fixed_width[index]); + }); + }); + + resizable_rows.forEach((row) => { + let fixed_size = 0; + + for (const child of row.tr.children) { + if(!row.variable_width_elements.includes(child)) { + fixed_size += child.offsetWidth; + } + } + + let width_factor_sum = 0; + for(const width_factor of row.variable_width_factor) { + width_factor_sum += width_factor; + } + + row.variable_width_elements.forEach((item, index) => { + resizable_auto_size(item, row.variable_width_factor[index] / width_factor_sum, fixed_size, table_width_factor); + }); + + }); +} + +function resizable_auto_size(parrent_el, factor, fixed_size, table_width_factor) { + parrent_el.classList.add("auto_size_parrent"); + parrent_el.style.width = (table_width_factor * window.innerWidth - fixed_size) * factor + 'px'; + parrent_el.setAttribute('resize_factor', factor); + parrent_el.setAttribute('fixed_size', fixed_size); + parrent_el.setAttribute('table_width_factor', table_width_factor); + + auto_size(parrent_el, (table_width_factor * window.innerWidth - fixed_size) * factor); + + if(!has_resize_event) { + window.addEventListener('resize', (ev) => { + const resize_parrents = document.querySelectorAll('.auto_size_parrent'); + resize_parrents.forEach((item) => { + + const factor = item.getAttribute('resize_factor'); + const fixed_size = item.getAttribute('fixed_size'); + const table_width_factor = item.getAttribute('table_width_factor'); + + item.style.width = (table_width_factor * window.innerWidth - fixed_size) * factor + 'px'; + auto_size(item, (table_width_factor * window.innerWidth - fixed_size) * factor); + }); + }); + + has_resize_event = true; + } + +} + + +function auto_size(parrent_el, parrent_width) { + if(!parrent_width) { + parrent_width = parrent_el.clientWidth; + } + + var child_width = 0; + for(const child of parrent_el.children) { + child_width += child.offsetWidth + 10; + } + + for(const child of parrent_el.children) { + var style = window.getComputedStyle(child, null).getPropertyValue('font-size'); + var fontSize = parseFloat(style); + if(!child.hasAttribute('original_font_size')){ + child.setAttribute('original_font_size', fontSize); + } + + const originalFontSize = child.getAttribute('original_font_size'); + + child.style.fontSize = Math.min(((fontSize + 1) * parrent_width/child_width), originalFontSize) + 'px'; + } + + /* + if(!has_resize_event) { + window.addEventListener('resize', (ev) => { + const resize_parrents = document.querySelectorAll('.auto_size_parrent'); + resize_parrents.forEach((item) => { + auto_size(item); + }); + }); + + has_resize_event = true; + } + */ +} + + +function render_match_table_header(table) { const thead = uiu.el(table, 'thead'); const title_tr = uiu.el(thead, 'tr'); - uiu.el(title_tr, 'th'); // Buttons holder - if (include_courts) { - uiu.el(title_tr, 'th', {}, ci18n('Court')); - } - uiu.el(title_tr, 'th', {}, '#'); + uiu.el(title_tr, 'th'); + uiu.el(title_tr, 'th', {}, ci18n('Court')); + uiu.el(title_tr, 'th', 'match_num', '#'); uiu.el(title_tr, 'th', {}, ci18n('Match')); uiu.el(title_tr, 'th', { + class: ('players'), colspan: 3, }, ci18n('Players')); uiu.el(title_tr, 'th', {}, ci18n('Umpire')); @@ -41,28 +204,52 @@ function render_match_table_header(table, include_courts) { uiu.el(title_tr, 'th', {}, ''); } -function render_match_row(tr, match, court, style) { - if (!court && match.setup.court_id) { - court = curt.courts_by_id[match.setup.court_id]; +function render_match_row(tr, match, court, style, show_player_status, show_add_tabletoperator) { + var resizable_elements = { tr: tr, + variable_width_elements : [], + variable_width_factor: [], + fixed_width_elements: [], + fixed_width: []}; + + if(!match.setup.is_match) { + return; + } + + court = resolve_match_court(match, court); + + const completeMatch = (match.setup.teams[0].players.length >= 1 && match.setup.teams[1].players.length >= 1); + + if (style === 'unasigned') { + if(completeMatch){ + tr.setAttribute('draggable', 'true'); + tr.addEventListener("dragstart", drag); + tr.addEventListener("dragend", dragend); + tr.classList.add('complete'); + } } + //if(! completeMatch) { + // tr.classList.add('incomplete'); + //} + + const waitForMatchStart = match.setup.called_timestamp && + ( match.network_score == undefined || + ( match.network_score[0] && + (match.network_score[0][0] + match.network_score[0][1] < 1) + ) + ); + const activeMatch = court && match.btp_winner != undefined; const setup = match.setup; - if (style === 'default' || style === 'plain') { - const actions_td = uiu.el(tr, 'td'); - const edit_btn = uiu.el(actions_td, 'div', { - 'class': 'vlink match_edit_button', - 'data-match__id': match._id, - 'title': ci18n('match:edit'), - }); - edit_btn.addEventListener('click', on_edit_button_click); - const scoresheet_btn = uiu.el(actions_td, 'div', { - 'class': 'vlink match_scoresheet_button', - 'title': ci18n('match:scoresheet'), - 'data-match__id': match._id, - }); - scoresheet_btn.addEventListener('click', on_scoresheet_button_click); + tr.setAttribute('data-match_id', match._id); + tr.setAttribute('data-style', style); + if (style === 'default' || style === 'plain' || style === 'unasigned') { + const actions_td = uiu.el(tr, 'td', 'actions'); + create_match_button(actions_td, 'vlink match_edit_button', 'match:edit', on_edit_button_click, match._id); + if(completeMatch) { + create_match_button(actions_td, 'vlink match_scoresheet_button', 'match:scoresheet', on_scoresheet_button_click, match._id); + } uiu.el(actions_td, 'a', { 'class': 'match_rawinfo', 'title': ci18n('match:rawinfo'), @@ -70,174 +257,1446 @@ function render_match_row(tr, match, court, style) { }); } - if (style === 'default') { - uiu.el(tr, 'td', {}, court ? court.num : ''); + if (style === 'default' || style === 'unasigned') { + const court_number_td = uiu.el(tr, 'td','court_number'); + if(court) { + uiu.el(court_number_td, 'span', 'court_history', court.num); + } else if (match.setup.location_id){ + const location = utils.find(curt.locations, l => l._id === match.setup.location_id); + uiu.el(court_number_td, 'span', 'location', "[" + location.short_name + "]"); + } + + if(match.setup.location_id) { + tr.setAttribute('data-location_id', match.setup.location_id); + } else { + tr.removeAttribute('data-location_id'); + } + + if (match.setup.location_id && !(window.localStorage.getItem('show_location_courts_' + match.setup.location_id) !== 'false')) { + tr.classList.add('do_not_show'); + } else { + tr.classList.remove('do_not_show'); + } + } + + + if (style === 'plain') { + const court_number_td = uiu.el(tr, "td", 'court_number'); + if(!court) + console.warn('no court'); + if(court.is_active) { + create_court_button(court_number_td, 'court_num', 'inactivate_court', on_inactivate_court_button_click, court._id, court.num); + } else { + create_court_button(court_number_td, 'court_inactive', 'activate_court', on_activate_court_button_click, court._id, ''); + } + } + + if(style === 'public') { + const court_number_td = uiu.el(tr, "td", {'class':'court_number', "data-court_id":court._id}); + if(court.is_active){ + uiu.el(court_number_td, "div", 'court_num', court.num); + } else { + uiu.el(court_number_td, "div", 'court_inactive', ""); + } } - if (style === 'default' || style === 'plain') { + if (style === 'default' || style === 'plain' || style === 'unasigned') { const match_str = (setup.scheduled_time_str ? (setup.scheduled_time_str + ' ') : '') + (setup.match_name ? (setup.match_name + ' ') : '') + setup.event_name; uiu.el(tr, 'td', 'match_num', setup.match_num); - uiu.el(tr, 'td', {}, match_str); + const match_properties_td = uiu.el(tr, 'td', 'match_properties', match_str); + if(! completeMatch) { + match_properties_td.classList.add('incomplete'); + } } else if (style === 'upcoming') { - uiu.el(tr, 'td', { - style: 'min-width: 0.8em;' - }, court ? court.num : ''); - uiu.el(tr, 'td', { - style: 'color: #aaa;', - }, `#${setup.match_num}`); - uiu.el(tr, 'td', { - style: 'color: #aaa;', - }, setup.scheduled_time_str || ''); - uiu.el(tr, 'td', { - style: 'color: #aaa;', - }, setup.event_name); + const court_number_td = uiu.el(tr, 'td','court_number_upcoming'); + if(court) { + uiu.el(court_number_td, 'span', 'court_upcoming', court.num); + } + uiu.el(tr, 'td', 'match_number_upcoming', `#${setup.match_num}`); + uiu.el(tr, 'td', 'match_scheduled_upcoming', setup.scheduled_time_str || ''); + const event_td = uiu.el(tr, 'td', 'match_event_upcoming'); + uiu.el(event_td, 'span', 'match_event_upcoming', setup.event_name); } const players0 = uiu.el(tr, 'td', { - 'class': ((match.team1_won === true) ? 'match_team_won' : ''), + 'class': ((match.team1_won === true) ? 'match_team_won' : 'match_team1'), style: 'text-align: right;', }); - render_players_el(players0, setup, 0); + + if(setup.teams[0].players.length < 1) { + players0.classList.add('incomplete'); + } + + if (style === 'default' || style === 'plain' || style === 'unasigned') { + if (show_add_tabletoperator) { + if (setup.teams[0].players.length > 0) { + create_match_button(players0, 'vlink tabletoperator_add_button', 'tabletoperator:add', on_add_to_tabletoperators_team_one_button_click, match._id); + } + } else { + create_match_button(players0, 'vlink match_second_call_button', 'match:secondcallteamone', on_second_call_team_one_button_click, match._id); + } + + if(style === 'unasigned' && match.setup.highlight >= 1){ + create_match_button(players0, 'vlink match_second_preparation_call_button', 'match:secondcallteamone', on_second_preparation_call_team_one_button_click, match._id); + } + } + + render_players_el(players0, setup, 0, match, show_player_status, style); uiu.el(tr, 'td', 'match_vs', 'v'); const players1 = uiu.el(tr, 'td', ((match.team1_won === false) ? 'match_team_won ' : '') + 'match_team2'); - render_players_el(players1, setup, 1); - if (style === 'default' || style === 'plain') { - const to_td = uiu.el(tr, 'td'); - if (setup.umpire_name) { - uiu.el(to_td, 'span', {}, setup.umpire_name); - if (setup.service_judge_name) { - uiu.el(to_td, 'span', {}, '\u200B+'); - uiu.el(to_td, 'span', {}, setup.service_judge_name); + + if(setup.teams[1].players.length < 1) { + players1.classList.add('incomplete'); + } + + render_players_el(players1, setup, 1, match, show_player_status, style); + if (style === 'default' || style === 'plain' || style === 'unasigned') { + if(style === 'unasigned' && match.setup.highlight >= 1){ + create_match_button(players1, 'vlink match_second_preparation_call_button', 'match:secondcallteamtwo', on_second_preparation_call_team_two_button_click, match._id); + } + + if (show_add_tabletoperator) { + if (setup.teams[1].players.length > 0) { + create_match_button(players1, 'vlink tabletoperator_add_button', 'tabletoperator:add', on_add_to_tabletoperators_team_two_button_click, match._id); } + } else { + create_match_button(players1, 'vlink match_second_call_button', 'match:secondcallteamtwo', on_second_call_team_two_button_click, match._id); + } + } + + if(style === 'public' || style === 'upcoming') { + + if(style === 'upcoming') { + players0.classList.add('match_team1_upcoming'); + players1.classList.add('match_team2_upcoming'); } else { - uiu.el( - to_td, 'span', - (setup.umpire_name ? ('match_umpire match_umpire_style_' + style) : 'match_no_umpire'), - ci18n('No umpire') - ); + players0.classList.add('match_team1_public'); + players1.classList.add('match_team2_public'); } + + resizable_elements.variable_width_elements.push(players0); + resizable_elements.variable_width_factor.push(1); + + resizable_elements.variable_width_elements.push(players1); + resizable_elements.variable_width_factor.push(1); } - if (style === 'default' || style === 'plain'/* || style === 'public' WIP */) { - const score_td = uiu.el(tr, 'td'); - if (court && (court.match_id !== match._id) && (typeof match.team1_won !== 'boolean') && setup.umpire_name) { - const ready_text = (style === 'public') ? ci18n('Ready') : ci18n(' Ready to start '); - uiu.el(score_td, 'span', {}, ready_text); + if(style != 'public') { + const to_td = uiu.el(tr, 'td', 'umpire_and_tablet'); + const show_participant_check_in_status = (style === 'unasigned'); + if (style === 'default' || style === 'plain' || style === 'unasigned') { + if (setup.umpire && setup.umpire.name) { + const umpire_span = render_match_participant_el(to_td, setup.umpire, match._id, 'umpire', 'umpire', show_participant_check_in_status); + + if (style === 'unasigned' && match.setup.highlight >= 1) { + create_match_button(umpire_span, 'vlink match_second_preparation_call_button', 'match:secondcallumpire', on_second_preparation_call_umpire_button_click, match._id); + } + create_match_button(umpire_span, 'vlink match_second_call_button', 'match:secondcallumpire', on_second_call_umpire_button_click, match._id); + + if (setup.service_judge && setup.service_judge.name) { + const service_judge_span = render_match_participant_el(to_td, setup.service_judge, match._id, 'service_judge', 'service_judge', show_participant_check_in_status); + if (style === 'unasigned' && match.setup.highlight >= 1) { + create_match_button(service_judge_span, 'vlink match_second_preparation_call_button', 'match:secondcallservicejudge', on_second_preparation_call_servicejudge_button_click, match._id); + } + create_match_button(service_judge_span, 'vlink match_second_call_button', 'match:secondcallservicejudge', on_second_call_servicejudge_button_click, match._id); + } else { + const umpire_icon = umpire_span.querySelector('.umpire'); + if (umpire_icon) { + umpire_icon.classList.add('can_add_service_judge'); + umpire_icon.setAttribute('title', ci18n('match:add_service_judge')); + umpire_icon.setAttribute('data-match_id', match._id); + umpire_icon.addEventListener('click', on_add_service_judge_button); + } + } + } + if (setup.tabletoperators && setup.tabletoperators.length > 0) { + const tablet_div = uiu.el(to_td, 'div', 'tablet_operator', ''); + + const operators_div = uiu.el(tablet_div, 'div', 'operators'); + setup.tabletoperators.forEach((operator) => { + render_match_participant_el(operators_div, operator, match._id, 'tabletoperator', 'tablet', show_participant_check_in_status); + }); + + if(style === 'unasigned' && match.setup.highlight >= 1){ + create_match_button(tablet_div, 'vlink match_second_preparation_call_button', 'match:secondcaltabletoperator', on_second_preparation_call_tabletoperator_button_click, match._id); + } + + if (style === 'default' || style === 'plain' || style === 'unasigned') { + create_match_button(tablet_div, 'vlink match_second_call_button', 'match:secondcaltabletoperator', on_second_call_tabletoperator_button_click, match._id); + } + } + + if (!setup.umpire && (!setup.tabletoperators || setup.tabletoperators.length == 0)) { + const no_umpire_span = uiu.el(to_td, 'span', 'person'); + const no_umpire_button_class = style === 'unasigned' ? 'vlink no_umpire no_umpire_add' : 'vlink no_umpire'; + create_match_button(no_umpire_span, no_umpire_button_class, 'match:add_umpire', on_add_officials_button, match._id); + uiu.el(no_umpire_span, 'span', 'match_no_umpire', ci18n('No umpire')); + + } + } else if(style === 'upcoming' && setup.highlight >= 1) { + var preparation_container = uiu.el(to_td, 'div', 'preparation_container'); + uiu.el(preparation_container, 'span', 'preparation', 'in Vorbereitung' + (setup.location_id ? "" : "!")); + + if(setup.location_id) { + const l = utils.find(curt.locations, l => l._id === setup.location_id); + if(l) { + uiu.el(preparation_container, 'span', 'preparation', l.preparation_addition); //TODO: Hie die Halle mit ausgeben! + } + } + + } + } + + + if(style != 'upcoming') { + const score_td = uiu.el(tr, 'td', 'score'); + if(style === 'public') { + score_td.classList.add('score_public'); } - uiu.el(score_td, 'span', { - 'class': ('match_score' + ((court && (court.match_id === match._id)) ? ' match_score_current' : '')), + + + const score_span = uiu.el(score_td, 'span', { + 'class': ('match_score' + ((match.setup.now_on_court === true) ? ' match_score_current' : '')), 'data-match_id': match._id, }, calc_score_str(match)); + + if(style === 'public' && calc_score_str(match) === '') { + if (setup.umpire && setup.umpire.firstname && setup.umpire.surname) { + const umpire_icon = uiu.el(score_span, 'div', 'umpire', ''); + const umpire_name_div = uiu.el(score_span, 'div', 'umpire_name_public'); + uiu.el(umpire_name_div, 'span', {}, short_name(setup.umpire.firstname, setup.umpire.surname)); + if (setup.service_judge && setup.service_judge.firstname && setup.service_judge.surname) { + uiu.el(umpire_name_div, 'span', {}, ' \u200B+ '); + uiu.el(umpire_name_div, 'span', {}, short_name(setup.service_judge.firstname, setup.service_judge.surname)); + } + + let parrent_width = score_span.clientWidth; + parrent_width -= parseFloat(window.getComputedStyle(score_span, null).getPropertyValue('padding-left')); + parrent_width -= parseFloat(window.getComputedStyle(score_span, null).getPropertyValue('padding-right')); + + //auto_size(umpire_name_div, parrent_width - umpire_icon.offsetWidth - 20); + resizable_elements.fixed_width_elements.push(umpire_name_div); + resizable_elements.fixed_width.push(parrent_width - umpire_icon.offsetWidth - 20); + + } else if (setup.tabletoperators && setup.tabletoperators.length > 0){ + const tablet_icon = uiu.el(score_span, 'div', 'tablet', ''); + const operators_div = uiu.el(score_span, 'div', 'operators_public') + uiu.el(operators_div, 'span', 'match_no_umpire', short_name(setup.tabletoperators[0].firstname, setup.tabletoperators[0].lastname, setup.tabletoperators[0].name)); + if (setup.tabletoperators.length > 1) { + uiu.el(operators_div, 'span', 'match_no_umpire', ' \u200B/ '); + uiu.el(operators_div, 'span', 'match_no_umpire', short_name(setup.tabletoperators[1].firstname, setup.tabletoperators[1].lastname, setup.tabletoperators[1].name)); + } + + let parrent_width = score_span.clientWidth; + parrent_width -= parseFloat(window.getComputedStyle(score_span, null).getPropertyValue('padding-left')); + parrent_width -= parseFloat(window.getComputedStyle(score_span, null).getPropertyValue('padding-right')); + + //auto_size(operators_div, parrent_width - tablet_icon.offsetWidth - 20); + + resizable_elements.fixed_width_elements.push(operators_div); + resizable_elements.fixed_width.push(parrent_width - tablet_icon.offsetWidth - 20 - 20); + } + } } - if (style === 'default' || style === 'plain') { - const shuttle_td = uiu.el(tr, 'td', 'match_shuttle_count'); + + if ((style === 'default' || style === 'plain')) { + const shuttle_td = uiu.el(tr, 'td', {'class': 'match_shuttle_count', 'data-match_id': match._id}); + if(match.shuttle_count) { + shuttle_td.classList.add('match_shuttle_count_display_active'); + } + uiu.el(shuttle_td, 'span', { 'class': ( - 'match_shuttle_count_display' + - (match.shuttle_count ? ' match_shuttle_count_display_active' : '') + 'match_shuttle_count_number' ), 'data-match_id': match._id, }, match.shuttle_count || ''); + + const shuttle_image = uiu.el(shuttle_td, 'div', { + 'class' : ( + 'match_shuttle_image' + ), + 'data-match_id': match._id}); + if(!match.shuttle_count) { + shuttle_image.style.display = 'none'; + } + else { + shuttle_image.style.display = 'inline-block'; + } + } + + if ((style === 'default' || style === 'plain' || style === 'unasigned')){ + const timer_td = uiu.el(tr, 'td', {'class': 'match_timer', 'data-match_id': match._id}); + + var timer_state = _extract_match_timer_state(match); + var timer = create_timer(timer_state, timer_td, "#cccccc", "#ff0000"); + if (timer) { + active_timers.matches[match._id] = timer; + } else { + var preparation_timer_state = _extract_preparation_timer_state(match); + var preparation_timer = create_timer(preparation_timer_state, timer_td, "#cccccc", "#ff0000"); + if (preparation_timer) { + active_timers.matches[match._id] = preparation_timer; + } + } + + if(style == 'plain' && match_scoring.is_match_over(match.network_score, match.setup.scoring_format)) { + create_match_button(timer_td, 'vlink match_confirm_button', 'Confirm_Finish', on_match_confirm_button_click, match._id); + } + } + + if (style === 'default' || style === 'plain' || style === 'unasigned') { + const call_td = uiu.el(tr, 'td', 'call_td'); + + if (style === 'unasigned' && completeMatch) { + const locations = curt.locations; + locations.forEach((l)=> { + if(window.localStorage.getItem('show_location_courts_' + l._id) !== 'false') { + create_match_prepparation_button(call_td, 'vlink match_preparation_call_button', 'match:preparationcall', on_announce_preparation_matchbutton_click, match._id, l); + } + }); + } else if ((style === 'default' || style === 'plain') && court) { + create_match_button(call_td, 'vlink match_manual_call_button', 'match:manualcall', on_announce_match_manually_button_click, match._id); + create_match_button(call_td, 'vlink match_begin_to_play_button', 'match:begintoplay', on_begin_to_play_button_click, match._id); + } + } + + if(!waitForMatchStart) { + uiu.qsEach('.match_second_call_button[data-match_id=' + JSON.stringify(match._id) + ']', (button_el) => { + if(match.setup.now_on_court) { + button_el.style.visibility = 'hidden'; + } else { + uiu.hide(button_el); + } + }); + uiu.qsEach('.match_begin_to_play_button[data-match_id=' + JSON.stringify(match._id) + ']', (button_el) => { + if(match.setup.now_on_court) { + button_el.style.visibility = 'hidden'; + } else { + uiu.hide(button_el); + } + }); + uiu.qsEach('.match_manual_call_button[data-match_id=' + JSON.stringify(match._id) + ']', (button_el) => { + if (match.setup.now_on_court) { + button_el.style.visibility = 'hidden'; + } else { + uiu.hide(button_el); + } + }); + } + + return resizable_elements; +} + +const on_add_officials_button = (e) => { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'add_officials_to_match', + tournament_key: curt.key, + match_id: match._id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +}; + +const on_add_service_judge_button = (e) => { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'add_service_judge_to_match', + tournament_key: curt.key, + match_id: match._id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +}; + +function short_name (first_names, last_name, name) { + if(first_names && last_name){ + const split_name = first_names.split(" "); + return split_name[0][0] + '. ' + last_name; + } + return name; +} + +function create_match_button(targetEl, cssClass, title, listener, matchId,) { + const btn = uiu.el(targetEl, 'div', { + 'class': cssClass, + 'title': ci18n(title), + 'data-match_id': matchId, + }); + btn.draggable = false; + btn.addEventListener('mousedown', function (ev) { + ev.stopPropagation(); + }); + btn.addEventListener('dragstart', function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + }); + btn.addEventListener('click', function (ev) { + ev.stopPropagation(); + listener(ev); + }); +} + +function create_match_prepparation_button(targetEl, cssClass, title, listener, matchId, location){ + const btn = uiu.el(targetEl, 'div', { + 'class': cssClass, + 'title': ci18n(title) + (location.preparation_addition ? ' ' + location.preparation_addition : ''), + 'data-match_id': matchId, + 'data-location_id': location._id, + }); + + uiu.el(btn, 'img', { + style: 'height: 1.2em; margin-top: 0.2em;', + src: location.logo_id ? '/h/' + encodeURIComponent(curt.key) + '/logo/' + location.logo_id : '/static/icons/preparation.svg', + name: 'location_logo_img', + 'data-match_id': matchId, + 'data-location_id': location._id + }); + + btn.addEventListener('click', listener); +} + +function update_match_score(m) { + uiu.qsEach('.match_score[data-match_id=' + JSON.stringify(m._id) + ']', function(score_el) { + uiu.text(score_el, calc_score_str(m)); + }); + + uiu.qsEach('.match_timer[data-match_id=' + JSON.stringify(m._id) + ']', (timer_td) => { + while (timer_td.firstChild) { + timer_td.removeChild(timer_td.lastChild); + } + + var timer_state = _extract_match_timer_state(m); + var timer = create_timer(timer_state, timer_td, "#cccccc", "#ff0000"); + if (timer) { + active_timers.matches[m._id] = timer; + } else { + var preparation_timer_state = _extract_preparation_timer_state(m); + var preparation_timer = create_timer(preparation_timer_state, timer_td, "#cccccc", "#ff0000"); + if (preparation_timer) { + active_timers.matches[m._id] = preparation_timer; + } + } + + if (match_scoring.is_match_over(m.network_score, m.setup.scoring_format)) { + create_match_button(timer_td, 'vlink match_confirm_button', 'Confirm_Finish', on_match_confirm_button_click, m._id); + } + }); + + if( m.network_score && m.network_score.length > 0 && + m.network_score[0].length > 1 && + (m.network_score[0][0] > 0 || m.network_score[0][1] > 0) ) { + uiu.qsEach('.match_second_call_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'hidden'; + }); + uiu.qsEach('.match_begin_to_play_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'hidden'; + }); + uiu.qsEach('.match_manual_call_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'hidden'; + }); + } else { + uiu.qsEach('.match_second_call_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'visible'; + }); + uiu.qsEach('.match_begin_to_play_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'visible'; + }); + uiu.qsEach('.match_manual_call_button[data-match_id=' + JSON.stringify(m._id) + ']', (button_el) => { + button_el.style.visibility = 'visible'; + }); + } + + uiu.qsEach('.match_shuttle_count[data-match_id=' + JSON.stringify(m._id) + ']', function(el) { + uiu.setClass(el, 'match_shuttle_count_display_active', !!m.shuttle_count); + }); + + uiu.qsEach('.match_shuttle_count_number[data-match_id=' + JSON.stringify(m._id) + ']', function(el) { + uiu.text(el, m.shuttle_count || ''); + }); + + uiu.qsEach('.match_shuttle_image[data-match_id=' + JSON.stringify(m._id) + ']', function(shuttle_image) { + if(!m.shuttle_count) { + shuttle_image.style.display = 'none'; + } + else { + shuttle_image.style.display = 'inline-block'; + } + + }); +} + +function on_match_confirm_button_click(e) { + const match_id = e.target.getAttribute('data-match_id'); + const match = utils.find(curt.matches, m => m._id === match_id); + if (match) { + send({ + type: 'confirm_match_finished', + match_id: match_id, + tournament_key: match.tournament_key, + court_id: match.setup.court_id + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + } +} + +function _get_match_planning_id(match) { + return match && match.btp_match_ids && match.btp_match_ids[0] ? match.btp_match_ids[0].planning : null; +} + +function _same_dependency_pair(links_a, links_b) { + if (!links_a || !links_b) { + return false; + } + return links_a.from1 == links_b.from1 && links_a.from2 == links_b.from2; +} + +function _get_source_planning_for_team(match, team_id) { + const links = match && match.setup ? match.setup.links : null; + if (!links) { + return null; + } + return team_id === 0 ? links.from1 : links.from2; +} + +function _find_direct_predecessor_match(match, team_id, matches) { + const source_planning = _get_source_planning_for_team(match, team_id); + if (source_planning == null) { + return null; + } + const source_candidates = (matches || []).filter((candidate) => { + if (!candidate || candidate._id === match._id || !candidate.setup) { + return false; + } + return _get_match_planning_id(candidate) == source_planning; + }); + + const direct_match = utils.find(source_candidates, (candidate) => candidate.setup.is_match); + if (direct_match) { + return direct_match; + } + + const placeholder = source_candidates[0]; + if (!placeholder || !placeholder.setup || !placeholder.setup.links) { + return null; + } + + return utils.find(matches || [], (candidate) => { + if (!candidate || candidate._id === match._id || !candidate.setup || !candidate.setup.is_match) { + return false; + } + return _same_dependency_pair(candidate.setup.links, placeholder.setup.links); + }) || null; +} + +function _find_matches_feeding_planning(source_planning, matches) { + if (source_planning == null) { + return []; + } + const seen = new Set(); + const incoming = []; + (matches || []).forEach((candidate) => { + if (!candidate || !candidate._id || !candidate.setup || !candidate.setup.is_match || !candidate.setup.links) { + return; + } + let relation = null; + if (candidate.setup.links.winner_to == source_planning) { + relation = 'Winner'; + } else if (candidate.setup.links.loser_to == source_planning) { + relation = 'Loser'; + } + if (!relation || seen.has(candidate._id)) { + return; + } + seen.add(candidate._id); + incoming.push({ match: candidate, relation }); + }); + return incoming; +} + +function _format_dependency_from_incoming_edges(source_planning, matches) { + const incoming = _find_matches_feeding_planning(source_planning, matches); + if (incoming.length === 0) { + return null; + } + if (incoming.length > 1) { + const unique_relations = [...new Set(incoming.map((entry) => entry.relation))]; + const incoming_plannings = incoming + .map((entry) => _get_match_planning_id(entry.match)) + .filter((planning) => planning != null); + if (unique_relations.length === 1 && incoming_plannings.length === incoming.length) { + const consolidation_match = utils.find(matches || [], (candidate) => { + if (!candidate || !candidate.setup || !candidate.setup.is_match || !candidate.setup.links) { + return false; + } + const candidate_sources = [candidate.setup.links.from1, candidate.setup.links.from2]; + return incoming_plannings.every((planning) => candidate_sources.includes(planning)); + }); + if (consolidation_match) { + return ci18n(unique_relations[0]) + " #" + consolidation_match.setup.match_num + " - " + consolidation_match.setup.scheduled_date + " " + consolidation_match.setup.scheduled_time_str; + } + } + } + if (incoming.length === 1) { + const entry = incoming[0]; + return ci18n(entry.relation) + " #" + entry.match.setup.match_num + " - " + entry.match.setup.scheduled_date + " " + entry.match.setup.scheduled_time_str; + } + + const unique_relations = [...new Set(incoming.map((entry) => entry.relation))]; + const match_refs = incoming + .map((entry) => "#" + entry.match.setup.match_num) + .sort((a, b) => cbts_utils.cmp(a, b)); + if (unique_relations.length === 1) { + return ci18n(unique_relations[0]) + " " + match_refs.join(' / '); + } + return match_refs.join(' / '); +} + +function _format_participant_dependency(match, team_id, matches) { + const links = match && match.setup ? match.setup.links : null; + if (!links) { + return '???'; + } + + const direct_link_label = team_id === 0 ? links.from1_link : links.from2_link; + if (direct_link_label) { + return direct_link_label; + } + + const predecessor = _find_direct_predecessor_match(match, team_id, matches); + if (predecessor && predecessor.setup && predecessor.setup.links) { + const current_planning = _get_match_planning_id(match); + if (current_planning != null && predecessor.setup.links.winner_to == current_planning) { + return ci18n('Winner') + " #" + predecessor.setup.match_num + " - " + predecessor.setup.scheduled_date + " " + predecessor.setup.scheduled_time_str; + } + if (current_planning != null && predecessor.setup.links.loser_to == current_planning) { + return ci18n('Loser') + " #" + predecessor.setup.match_num + " - " + predecessor.setup.scheduled_date + " " + predecessor.setup.scheduled_time_str; + } + } + const source_planning = _get_source_planning_for_team(match, team_id); + const incoming_dependency = _format_dependency_from_incoming_edges(source_planning, matches); + if (incoming_dependency) { + return incoming_dependency; + } + return '???'; +} + +function render_players_el(parentNode, setup, team_id, match, show_player_status, style) { + const team = setup.teams[team_id]; + + const nat0 = team.players[0] && team.players[0].nationality; + if (curt.is_nation_competition && nat0) { + cflags.render_flag_el(parentNode, nat0); + } + + if (team.players.length > 0) { + if(team.entry_status !== ""){ + uiu.el(parentNode, 'span', {}, team.entry_status); + } + render_player_el(parentNode, team.players[0], match._id, setup.now_on_court, show_player_status, style, team.players.length > 1 ? true : false); + } else { + const dependency = _format_participant_dependency(match, team_id, curt.matches); + uiu.el(parentNode, 'span', {}, dependency); + } + + if (team.players.length > 1) { + uiu.el(parentNode, 'span', {}, ' / '); + + const nat1 = team.players[1] && team.players[1].nationality; + const p1_el = uiu.el(parentNode, 'span', { + 'style': 'white-space: pre', + }); + if (curt.is_nation_competition && nat1 && (nat1 !== nat0)) { + cflags.render_flag_el(p1_el, nat1); + } + + render_player_el(parentNode, team.players[1], match._id, setup.now_on_court, show_player_status, style, true); + } +} + +function render_match_participant_el(parentNode, participant, match_id, role, icon_class, show_check_in_status = true) { + const technical_official_check_in_locked = + (role === 'umpire' || role === 'service_judge') && + curt && + curt.btp_settings && + curt.btp_settings.check_in_per_match === false; + const participant_checked_in = technical_official_check_in_locked ? true : !!(participant && participant.checked_in); + const participant_status = show_check_in_status && participant_checked_in ? 'checked_in' : (show_check_in_status ? 'not_checked_in' : 'no_status'); + const participant_el = uiu.el(parentNode, 'span', { + 'class': 'person ' + participant_status, + 'data-btp_id': participant.btp_id, + 'data-match_id': match_id, + }, participant.name || short_name(participant.firstname, participant.lastname || participant.surname, participant.name)); + + participant_el.innerHTML = ''; + uiu.el(participant_el, 'div', icon_class, ''); + const name_el = uiu.el(participant_el, 'span', 'name', participant.name || short_name(participant.firstname, participant.lastname || participant.surname, participant.name)); + + if (show_check_in_status && participant.btp_id != null && participant.btp_id >= 0 && !technical_official_check_in_locked) { + if (participant_status === 'checked_in') { + participant_el.classList.add('can_check_out'); + } else { + participant_el.classList.add('can_check_in'); + } + + name_el.classList.add('person_status_target'); + name_el.addEventListener('click', function(ev) { + send({ + type: 'match_participant_check_in', + match_id, + role, + participant_id: participant.btp_id, + checked_in: participant_status === 'not_checked_in', + tournament_key: curt.key + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + ev.stopPropagation(); + ev.preventDefault(); + }, false); + } + + return participant_el; +} + +function render_player_el(parentNode, player, match_id, now_on_court, show_player_status, style, is_doubles) { + let player_status = get_player_status(player, now_on_court, show_player_status); + const player_name = (style === 'public' || style === 'upcoming' && is_doubles) ? short_name(player.firstname, player.lastname) : player.name; + let player_element = uiu.el(parentNode, 'span', { + 'class' : 'person player ' + player_status + (style === 'public' || style === 'upcoming' ? '_public' : ''), + 'data-btp_id' : player.btp_id, + 'data-match_id': match_id, + }, player_name.replace(' ', '\xa0')); + + if(player.check_in_per_match) { + if(player_status == "checked_in") { + player_element.classList.add("can_check_out"); + } else if (player_status == "not_checked_in") { + player_element.classList.add("can_check_in"); + } + } + + + player_element.addEventListener("click", (ev) => { + if(curt.btp_settings.check_in_per_match) { + send({ + type: 'match_player_check_in', + match_id, + player_id: player.btp_id, + checked_in: (player_status == "not_checked_in"), + tournament_key: curt.key + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + } + ev.stopPropagation(); + ev.preventDefault(); + }, false); + + + if ((player.now_playing_on_court && player_status != "now_on_court") && player_status != "no_status") { + let parts = player.now_playing_on_court.split("_"); + let court_number = parts[parts.length - 1]; + uiu.el(player_element, 'div', 'court', court_number); + } + + if(player.now_tablet_on_court) { + let parts = player.now_tablet_on_court.split("_"); + let court_number = parts[parts.length - 1]; + uiu.el(player_element, 'div', 'tablet_inline', court_number); + } + + if(show_player_status && player_status != "now_on_court") { + var timer_state = _extract_player_timer_state(player); + var timer = create_timer(timer_state, player_element, "#ffffff", "#ffffff"); + } +} + +function get_player_status(player, now_on_court, show_player_status) { + let player_status = ""; + if (!show_player_status) { + player_status = "no_status"; + } else if(now_on_court) { + player_status = "now_on_court"; + } else if (player.now_playing_on_court) { + player_status = "now_playing"; + } else if (player.checked_in) { + player_status = "checked_in"; + } else { + player_status = "not_checked_in"; + } + + + + return player_status; +} + +function update_players(m) { + if(m.setup.teams) { + m.setup.teams.forEach((team) => { + if(team.players) { + team.players.forEach((player) => { + update_player(m._id, player, m.setup.now_on_court, m.btp_winner === undefined); + }); + } + }); + } + +} + +function update_player(match_id, player, now_on_court, show_player_status) { + uiu.qsEach('.player[data-match_id=' + JSON.stringify(match_id) + '][data-btp_id="' + JSON.stringify(player.btp_id) + '"]' , function(player_el) { + let player_status = get_player_status(player, now_on_court, show_player_status); + + player_el.classList.remove("now_on_court", "now_playing", "checked_in", "not_checked_in", "no_status", "can_check_out", "can_check_in"); + player_el.classList.add(player_status); + if(player.check_in_per_match) { + if(player_status == "checked_in") { + player_el.classList.add("can_check_out"); + } else if (player_status == "not_checked_in") { + player_el.classList.add("can_check_in"); + } + } + + //The only Child should be the now_playing_on_court icon or the now_tablet_on_court icon + while (player_el.firstElementChild) { + player_el.removeChild(player_el.lastElementChild); + } + + if ((player.now_playing_on_court && player_status != "now_on_court") && player_status != "no_status") { + let parts = player.now_playing_on_court.split("_"); + let court_number = parts[parts.length - 1]; + uiu.el(player_el, 'div', 'court', court_number); + } + + if(player.now_tablet_on_court) { + let parts = player.now_tablet_on_court.split("_"); + let court_number = parts[parts.length - 1]; + uiu.el(player_el, 'div', 'tablet_inline', court_number); + } + + if(show_player_status && player_status != "now_on_court") { + var timer_state = _extract_player_timer_state(player); + var timer = create_timer(timer_state, player_el, "#ffffff", "#ffffff"); + } + + }); +} + + function remove_match_from_gui(m, old_section) { + switch (old_section) { + case 'finished': + case 'unassigned': + uiu.qsEach('.match[data-match_id=' + JSON.stringify(m._id) + ']', (match_row_el) => { + match_row_el.remove(); + }); + break; + default: + const main_container = document.getElementsByClassName('main_upcoming'); + if (main_container.length > 0){ + uiu.qsEach('.court_row[data-court_id=' + JSON.stringify(m.setup.court_id) + ']', (match_row_el) => { + const c = utils.find(curt.courts, c => c._id === m.setup.court_id); + match_row_el.innerHTML = ""; + render_empty_court_row(match_row_el, c, 'public', false); + }); + } else { + uiu.qsEach('.court_row[data-court_id=' + JSON.stringify(old_section.slice(6, old_section.length)) + ']', (match_row_el) => { + const c = utils.find(curt.courts, c => c._id === m.setup.court_id); + match_row_el.innerHTML = ""; + render_empty_court_row(match_row_el, c, 'plain', true); + }); + } + break; + } + } + +function add_match(m, section) { + insert_new_match_row(m, section); +} + +function insert_new_match_row(m, section) { + switch (section) { + case 'finished': + uiu.qsEach('.finished_container', (finished_container) => { + const tbody = finished_container.querySelector('.match_table > tbody'); + const match_row_el = uiu.el(tbody, 'tr', {'class' : 'match highlight_' + m.setup.highlight , 'data-match_id': m._id}); + render_match_row(match_row_el, m, null, 'default', false, curt.tabletoperator_enabled); + for (const child of tbody.children) { + const child_btp_id = child.dataset.match_id; + const child_match = utils.find(curt.matches, m => 'btp_'+m.btp_id === child_btp_id); + if(child_match) { + if(cmp_end_ts_match_order(m, child_match) < 0) { + tbody.insertBefore(match_row_el, child); + break; + } + } + } + }); + break; + case 'unassigned': + uiu.qsEach('.unassigned_container', (unassigned_container) => { + const tbody = unassigned_container.querySelector('.match_table > tbody'); + const match_row_el = uiu.el(tbody, 'tr', {'class' : 'match highlight_' + m.setup.highlight , 'data-match_id': m._id}); + render_match_row(match_row_el, m, null, 'unasigned', true, curt.tabletoperator_enabled); + for (const child of tbody.children) { + const child_btp_id = child.dataset.match_id; + const child_match = utils.find(curt.matches, m => 'btp_'+m.btp_id === child_btp_id); + if(child_match) { + if(cmp_scheduled_match_order(m, child_match) < 0) { + tbody.insertBefore(match_row_el, child); + break; + } + } + } + }); + break; + default: + const court = utils.find(curt.courts, c => c._id === m.setup.court_id); + uiu.qsEach('.court_row[data-court_id=' + JSON.stringify(m.setup.court_id) + ']', (match_row_el) => { + match_row_el.innerHTML = ""; + const closest = match_row_el.closest('.main_upcoming'); + if(Boolean(closest)) { + render_match_row(match_row_el, m, court, 'public', ); + } else { + render_match_row(match_row_el, m, court, 'plain', false, false); + } + }); + break; + } +} + +function update_match_row(m, new_section) { + uiu.qsEach('.match[data-match_id=' + JSON.stringify(m._id) + ']', (match_row_el) => { + match_row_el.innerHTML = ''; + + switch (new_section) { + case 'finished': + render_match_row(match_row_el, m, null, 'default', false, curt.tabletoperator_enabled); + break; + case 'unassigned': + match_row_el.setAttribute('class', 'match highlight_' + (m.setup.highlight ? m.setup.highlight : 0)); + render_match_row(match_row_el, m, null, 'unasigned', true, curt.tabletoperator_enabled); + break; + default: + const court = utils.find(curt.courts, c => c._id === m.setup.court_id); + const closest = match_row_el.closest('.main_upcoming'); + if(Boolean(closest)) { + render_match_row(match_row_el, m, court, 'public'); + } else { + render_match_row(match_row_el, m, court, 'plain', false, false); + } + break; + } + }); +} + +function update_match(m, old_section, new_section) { + if(old_section != new_section) { + remove_match_from_gui(m, old_section); + insert_new_match_row(m, new_section); + } else { + update_match_row(m, new_section); + } +} + +var active_timers = {'matches': {}, 'players' : {}}; + +function create_timer(timer_state, parent, default_color, exigent_color) { + + if (!timer_state) { + return; + } + + var tv = timer.calc(timer_state); + + if(!tv || !tv.visible){ + return; + } + + + var bgColor = timer_state.bgColor; + let el = uiu.el(parent, 'div', { class: 'timer', style: ('background-color:' + bgColor +'; color:' + default_color +';')}, tv.str); + + var tobj = {} + + var update = function() { + var tv = timer.calc(timer_state); + var visible = tv.visible; + + uiu.text (el, tv.str); + if(tv.exigent && exigent_color){ + el.style.color = exigent_color; + } + + if (visible && tv.next) { + tobj.timeout = setTimeout(update, tv.next); + el.style.display = "inline-block"; + } else { + tobj.timeout = null; + el.style.display = "none"; + } + }; + + update(); + + return tobj; +} + +function _extract_player_timer_state(player) { + let s = {}; + s.settings = {}; + s.settings.negative_timers = false; + s.lang = "de"; + s.timer = {}; + s.timer.duration = (curt && curt.btp_settings && curt.btp_settings.pause_duration_ms) ? curt.btp_settings.pause_duration_ms : 0; + s.timer.start = (player.last_time_on_court_ts ? player.last_time_on_court_ts : false); + s.timer.upwards = false; + s.timer.exigent = false; + + if (player.tablet_break_active) { + s.bgColor = "#0000ff"; + } else { + s.bgColor = "#ff0000"; + } + + return s; +} + +function _extract_preparation_timer_state(match) { + if (!match || !match.setup) { + return null; + } + if (match.setup.state !== 'preparation') { + return null; + } + if (!match.setup.highlight || match.setup.highlight <= 0) { + return null; + } + if (!match.setup.preparation_call_timestamp) { + return null; + } + + let s = {}; + s.settings = {}; + s.settings.negative_timers = false; + s.lang = "de"; + s.timer = {}; + s.timer.start = match.setup.preparation_call_timestamp; + s.timer.upwards = true; + s.timer.exigent = false; + s.bgColor = "#00000033"; + return s; +} + +function _extract_match_timer_state(match) { + var presses = match.presses; + + let s = {}; + s.settings = {}; + s.settings.negative_timers = true; + s.lang = (curt && curt.btp_settings && curt.btp_settings.language && curt.btp_settings.language !== 'auto') ? curt.btp_settings.language : "de"; + + try { + return calc.remote_state(s, match.setup, presses); + } catch (err) { + const label = match && match.setup && match.setup.match_num ? `#${match.setup.match_num}` : (match && match._id ? match._id : ''); + console.error(`[bts] calc.remote_state failed for ${label}`, err); + if (typeof cerror !== 'undefined' && cerror && cerror.silent) { + cerror.silent(`Timer state for ${label} could not be calculated: ${err.message}`); + } + return false; + } +} + +function cmp_scheduled_match_order(m1, m2) { + const time_str1 = m1.setup.scheduled_time_str; + const time_str2 = m2.setup.scheduled_time_str; + + if (time_str1 && !time_str2) { + return -1; + } else if (time_str2 && !time_str1) { + return 1; + } + + const cmp1 = cbts_utils.cmp(m1.setup.scheduled_date, m2.setup.scheduled_date); + if (cmp1 != 0) return cmp1; + + if (time_str1 === '00:00' && time_str2 === '00:00') { + return cbts_utils.cmp(m1.setup.match_num, m2.setup.match_num); + } else if (time_str1 === '00:00' && time_str2 !== '00:00') { + return 1; + } else if (time_str2 === '00:00' && time_str1 !== '00:00') { + return -1; + } + + const cmp2 = cbts_utils.cmp(time_str1, time_str2); + if (cmp2 != 0) return cmp2; + + if ((m1.match_order !== undefined) && (m2.match_order !== undefined)) { + const cmp_result = cbts_utils.cmp(m1.match_order, m2.match_order); + if (cmp_result != 0) return cmp_result; + } + + return cbts_utils.cmp(m1.setup.match_num, m2.setup.match_num); +} + +function cmp_end_ts_match_order(m1, m2) { + var m1_ts = m1.end_ts; + var m2_ts = m2.end_ts; + + if(!m1_ts) { + m1_ts = zoned_time_to_utc_timestamp(m1.setup.scheduled_date, m1.setup.scheduled_time_str, 'Europe/Berlin') / 2; + } + if(!m2_ts) { + m2_ts = zoned_time_to_utc_timestamp(m2.setup.scheduled_date, m2.setup.scheduled_time_str, 'Europe/Berlin') / 2; + } + return m1_ts - m2_ts +} + +function prepare_render(t) { + t.matches.sort((m1, m2) => {return cmp_scheduled_match_order(m1, m2)}); + + t.courts_by_id = {}; + for (const c of t.courts) { + t.courts_by_id[c._id] = c; + } +} + +function on_edit_button_click(e) { + const btn = e.target; + const match_id = btn.getAttribute('data-match_id'); + ui_edit(match_id); +} + +function on_scoresheet_button_click(e) { + const btn = e.target; + const match_id = btn.getAttribute('data-match_id'); + ui_scoresheet(match_id); +} +function on_announce_preparation_matchbutton_click(e) { + const match = fetchMatchFromEvent(e); + const location = fetchLocationFromEvent(e); + + if (match != null && location != null) { + send({ + type: 'match_preparation_call', + match: match, + location_id : location._id, + tournament_key: match.tournament_key, + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_add_to_tabletoperators_team_one_button_click(e) { + const match = fetchMatchFromEvent(e); + ctabletoperator.add_to_tabletoperator(match, 0) +} +function on_add_to_tabletoperators_team_two_button_click(e) { + const match = fetchMatchFromEvent(e); + ctabletoperator.add_to_tabletoperator(match,1) +} + + +function on_second_call_team_one_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_call_team_one', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_call_team_two_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_call_team_two', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_preparation_call_team_one_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_preparation_call_team_one', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_preparation_call_team_two_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_preparation_call_team_two', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_call_tabletoperator_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_call_tabletoperator', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_preparation_call_tabletoperator_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_preparation_call_tabletoperator', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function on_second_call_umpire_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_call_umpire', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + + +function on_second_preparation_call_umpire_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_preparation_call_umpire', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +function on_second_call_servicejudge_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_call_servicejudge', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + + +function on_second_preparation_call_servicejudge_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'second_preparation_call_servicejudge', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + + + +function on_begin_to_play_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'begin_to_play_call', + tournament_key: curt.key, + setup: match.setup, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +function on_announce_match_manually_button_click(e) { + const match = fetchMatchFromEvent(e); + if (match != null) { + send({ + type: 'announce_match_manually', + tournament_key: curt.key, + match: match, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} +function fetchMatchFromEvent(e) { + const btn = (e.currentTarget && e.currentTarget.getAttribute && e.currentTarget.getAttribute('data-match_id')) + ? e.currentTarget + : (e.target && e.target.closest ? e.target.closest('[data-match_id]') : null); + const match_id = btn ? btn.getAttribute('data-match_id') : null; + const match = utils.find(curt.matches, m => m._id === match_id); + if (!match) { + cerror.silent('Match ' + match_id + ' konnte nicht gefunden werden'); + return null; + } else { + return match; + } +} +function fetchLocationFromEvent(e) { + const btn = e.target; + const location_id = btn.getAttribute('data-location_id'); + const location = utils.find(curt.locations, l => l._id === location_id); + if (!location) { + cerror.silent('Location ' + location_id + ' konnte nicht gefunden werden'); + return null; + } else { + return location; + } +} +function _nation_team_name(nat0, nat1) { + if (nat1 && nat0 && (nat0 != nat1)) { + return countries.lookup(nat0) + ' / ' + countries.lookup(nat1); + } + if (nat0) { + return countries.lookup(nat0); } + return ''; } -function update_match_score(m) { - uiu.qsEach('.match_score[data-match_id=' + JSON.stringify(m._id) + ']', function(score_el) { - uiu.text(score_el, calc_score_str(m)); - }); - uiu.qsEach('.match_shuttle_count_display[data-match_id=' + JSON.stringify(m._id) + ']', function(el) { - uiu.text(el, m.shuttle_count || ''); - uiu.setClass(el, 'match_shuttle_count_display_active', !!m.shuttle_count); - }); -} - -function render_players_el(parentNode, setup, team_id) { - const team = setup.teams[team_id]; - if (setup.incomplete) { - uiu.el(parentNode, 'span', {}, ci18n('match:incomplete')); +function _pack_official_for_match_setup(official) { + if (!official) { + return official; } + return { + _id: official._id, + btp_id: official.btp_id, + name: official.name, + firstname: official.firstname, + surname: official.surname, + country: official.country, + is_umpire: !!official.is_umpire, + is_service_judge: !!official.is_service_judge, + checked_in: false, + }; +} - const nat0 = team.players[0] && team.players[0].nationality; - if (!curt.is_nation_competition || !nat0) { - uiu.el(parentNode, 'span', {}, team.players.map(p => p.name.replace(' ', '\xa0')).join(' / ')); - return; +function _update_setup(setup, d) { + if(!setup) { + return _make_setup(d); } - cflags.render_flag_el(parentNode, nat0); - uiu.el(parentNode, 'span', {}, team.players[0].name); - - if (team.players.length > 1) { - uiu.el(parentNode, 'span', {}, ' / '); + const result = setup; - const nat1 = team.players[1] && team.players[1].nationality; - const p1_el = uiu.el(parentNode, 'span', { - 'style': 'white-space: pre', - }); - if (nat1 && (nat1 !== nat0)) { - cflags.render_flag_el(p1_el, nat1); + let override_colors = undefined; + if (d.override_colors_checkbox) { + override_colors = {}; + for (let team_id = 0;team_id < 2;team_id++) { + const team_override_colors = {}; + for (const key of OVERRIDE_COLORS_KEYS) { + override_colors[key + team_id] = d[`override_colors_${team_id}_${key}`]; + } } - - const partner_name = team.players[1].name.replace(' ', '\xa0'); - uiu.el(p1_el, 'span', {}, partner_name); } -} - -function prepare_render(t) { - t.matches.sort(function(m1, m2) { - const time_str1 = m1.setup.scheduled_time_str; - const time_str2 = m2.setup.scheduled_time_str; - if (time_str1 && !time_str2) { - return -1; - } else if (time_str2 && !time_str1) { - return 1; + result.court_id = d.court_id; + result.now_on_court = !! d.now_on_court; + if (d.preparation_location_id) { + result.state = 'preparation'; + result.location_id = d.preparation_location_id; + if (!result.preparation_call_timestamp) { + result.preparation_call_timestamp = Date.now(); } + } else if (result.state === 'preparation') { + result.state = 'scheduled'; + result.highlight = 0; + delete result.location_id; + delete result.preparation_call_timestamp; + } - const cmp1 = cbts_utils.cmp(m1.setup.scheduled_date, m2.setup.scheduled_date); - if (cmp1 != 0) return cmp1; - - if (time_str1 === '00:00' && time_str2 !== '00:00') { - return 1; - } else if (time_str2 === '00:00' && time_str1 !== '00:00') { - return -1; - } + if(!d.umpire_name) { + delete result.umpire; + } - const cmp2 = cbts_utils.cmp(time_str1, time_str2); - if (cmp2 != 0) return cmp2; + if(!d.service_judge_name) { + delete result.service_judge; + } - if ((m1.match_order !== undefined) && (m2.match_order !== undefined)) { - const cmp_result = cbts_utils.cmp(m1.match_order, m2.match_order); - if (cmp_result != 0) return cmp_result; + for (const u of curt.umpires) { + if (u.name === d.umpire_name) { + result.umpire = _pack_official_for_match_setup(u); } - return cbts_utils.cmp(m1.setup.match_num, m2.setup.match_num); - }); - - t.courts_by_id = {}; - for (const c of t.courts) { - t.courts_by_id[c._id] = c; + if (u.name === d.service_judge_name) { + result.service_judge = _pack_official_for_match_setup(u); + } } -} - -function on_edit_button_click(e) { - const btn = e.target; - const match_id = btn.getAttribute('data-match__id'); - ui_edit(match_id); -} -function on_scoresheet_button_click(e) { - const btn = e.target; - const match_id = btn.getAttribute('data-match__id'); - ui_scoresheet(match_id); -} + result.override_colors = override_colors; -function _nation_team_name(nat0, nat1) { - if (nat1 && nat0 && (nat0 != nat1)) { - return countries.lookup(nat0) + ' / ' + countries.lookup(nat1); - } - if (nat0) { - return countries.lookup(nat0); - } - return ''; + return result; } function _make_setup(d) { @@ -290,7 +1749,8 @@ function _cancel_ui_edit() { } cbts_utils.esc_stack_pop(); uiu.remove(dlg); - ctournament.ui_show(); + + crouting.set('t/:key/', { key: curt.key }); } function _delete_match_btn_click(e) { @@ -308,13 +1768,36 @@ function _delete_match_btn_click(e) { _cancel_ui_edit(); }); } + function _finish_ui_edit(e) { + const match_id = e.target.getAttribute('data-match_id'); + const match = utils.find(curt.matches, m => m._id === match_id); + if (match) { + send({ + type: 'confirm_match_finished', + match_id: match_id, + tournament_key: match.tournament_key, + court_id: match.setup.court_id + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + } + _cancel_ui_edit(); + } function ui_edit(match_id) { - const match = utils.find(curt.matches, m => m._id === match_id); + const match = structuredClone(utils.find(curt.matches, m => m._id === match_id)); + let old_court = structuredClone(match.setup.court_id); if (!match) { cerror.silent('Match ' + match_id + ' konnte nicht gefunden werden'); return; } + + if(!old_court) { + old_court = "not_on_court" + } + crouting.set('t/' + curt.key + '/m/' + match_id + '/edit', {}, _cancel_ui_edit); cbts_utils.esc_stack_push(_cancel_ui_edit); @@ -346,6 +1829,7 @@ function ui_edit(match_id) { uiu.el(sendbtp_label, 'input', { type: 'checkbox', name: 'btp_update', + checked: 'true', }); sendbtp_label.appendChild(document.createTextNode('auch in BTP ändern')); } @@ -356,14 +1840,24 @@ function ui_edit(match_id) { }, ci18n('Change')); form_utils.onsubmit(form, function(d) { - const setup = _make_setup(d); + if (!d.umpire_name && d.service_judge_name) { + cerror.silent(ci18n('match:edit:error:service_judge_requires_umpire')); + return; + } + const previous_setup = structuredClone(match.setup); + match.setup = _update_setup(match.setup, d); + const force_btp_update = + (previous_setup.state || null) !== (match.setup.state || null) || + (previous_setup.location_id || null) !== (match.setup.location_id || null) || + (previous_setup.highlight || 0) !== (match.setup.highlight || 0); btn.setAttribute('disabled', 'disabled'); send({ type: 'match_edit', id: d.match_id, - setup, + match, + old_court, tournament_key: curt.key, - btp_update: (curt.btp_enabled && !! d.btp_update), + btp_update: (curt.btp_enabled && (!! d.btp_update || force_btp_update)), }, function match_edit_callback(err) { btn.removeAttribute('disabled'); if (err) { @@ -378,6 +1872,13 @@ function ui_edit(match_id) { 'data-match_id': match_id, }, ci18n('match:edit:delete')); delete_btn.addEventListener('click', _delete_match_btn_click); + + const finish_btn = uiu.el(buttons, 'span', { + 'class': 'match_cancel_link vlink', + 'data-match_id': match._id + }, ci18n('Confirm_Finish')); + finish_btn.addEventListener('click', _finish_ui_edit); + const cancel_btn = uiu.el(buttons, 'span', 'match_cancel_link vlink', ci18n('Cancel')); cancel_btn.addEventListener('click', _cancel_ui_edit); } @@ -433,7 +1934,13 @@ function ui_scoresheet(match_id) { i18n.register_lang(i18n_en); const setup = utils.deep_copy(match.setup); setup.tournament_name = curt.name; - const s = calc.remote_state(pseudo_state, setup, match.presses); + let s = null; + try { + s = calc.remote_state(pseudo_state, setup, match.presses); + } catch (err) { + console.error(`[bts] scoresheet remote_state failed for #${setup.match_num || '?'} (${match._id})`, err); + return cerror.silent(`Scoresheet for #${setup.match_num || '?'} cannot be rendered: ${err.message}`); + } s.ui = {}; printing.set_orientation('landscape'); @@ -471,79 +1978,409 @@ crouting.register(/t\/([a-z0-9]+)\/m\/([-a-zA-Z0-9_ ]+)\/scoresheet$/, function( ui_scoresheet(match_id); })); -function render_match_table(container, matches, include_courts) { +function render_match_table(container, matches, style, show_player_status, show_add_tabletoperator) { + if(!show_player_status) + { + show_player_status = false; + } + + if(! style) { + style = 'default'; + } + const table = uiu.el(container, 'table', 'match_table'); - render_match_table_header(table, include_courts); + render_match_table_header(table, true); const tbody = uiu.el(table, 'tbody'); for (const m of matches) { - const tr = uiu.el(tbody, 'tr'); - render_match_row(tr, m, null, include_courts ? 'default' : 'plain'); + if(m.setup.is_match) { + const tr = uiu.el(tbody, 'tr', {'class' : 'match highlight_' + m.setup.highlight , 'data-match_id': m._id}); + render_match_row(tr, m, null, style, show_player_status, show_add_tabletoperator); + } } + } function render_unassigned(container) { uiu.empty(container); - uiu.el(container, 'h3', {}, ci18n('Unassigned Matches')); + uiu.el(container, 'h3', 'section', ci18n('Unassigned Matches')); const unassigned_matches = curt.matches.filter(m => calc_section(m) === 'unassigned'); - render_match_table(container, unassigned_matches, curt.only_now_on_court); + render_match_table(container, unassigned_matches, 'unasigned', true, curt.tabletoperator_enabled); } function render_upcoming_matches(container) { - const UPCOMING_MATCH_COUNT = 10; + const UPCOMING_MATCH_COUNT = parseInt(curt.upcoming_matches_max_count ? curt.upcoming_matches_max_count : 15); uiu.empty(container); - uiu.el(container, 'h3', { + uiu.el(container, 'h2', { style: 'text-align: center;', - }, ci18n('Next Matches')); + }, ci18n('Next Matches')); const upcoming_table = uiu.el(container, 'table', 'upcoming_table'); - const unassigned_matches = curt.matches.filter(m => calc_section(m) === 'unassigned'); + const upcoming_tbody = uiu.el(upcoming_table, 'tbody', 'upcoming_tbody'); + + const locationById = Object.fromEntries( + curt.locations.map(l => [l._id, l]) + ); + + const params = new URLSearchParams(window.location.search); + const param_location = params.get("location"); + + const unassigned_matches = curt.matches.filter(m => { + if (calc_section(m) !== 'unassigned') return false; + if (!param_location) return true; + + const loc = locationById[m.setup.location_id]; + + // KEINE Location → trotzdem anzeigen + if (!loc) return true; + + // Location vorhanden → muss matchen + return loc.name === param_location; + }); + + var resizable_rows = []; for (const match of unassigned_matches.slice(0, UPCOMING_MATCH_COUNT)) { - const tr = uiu.el(upcoming_table, 'tr', { + const tr = uiu.el(upcoming_tbody, 'tr', { style: 'padding-top: 1em;', }); - render_match_row(tr, match, null, 'upcoming'); + resizable_rows.push(render_match_row(tr, match, null, 'upcoming')); + } + resize_table(resizable_rows, 0.98); + const qr = uiu.el(container, 'img', { + type: 'img', + id: 'main_q_code_upcoming', + src: curt.mainQrCode, + style: 'position: absolute; right: 20px; bottom: 20px;' + }); +} + + +function zoned_time_to_utc_timestamp(dateStr, timeStr, timeZone) { + if (!dateStr) { + return 0; + } + + if (!timeStr) { + return 0; } + + const [year, month, day] = dateStr.split('-').map(Number); + const [hour, minute] = timeStr.split(':').map(Number); + + // "Guess": als ob die eingegebene Zeit UTC wäre + const utcGuessMs = Date.UTC(year, month - 1, day, hour, minute, 0); + + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + // Wie sieht dieser utcGuess in der Ziel-Zeitzone aus? + const parts = Object.fromEntries( + formatter.formatToParts(new Date(utcGuessMs)).map(p => [p.type, p.value]) + ); + + // Diese "Zonen-Zeit" interpretieren wir als UTC, um den Offset zu bekommen + const asIfUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + // Offset = (Zonen-Darstellung als UTC) - (echte UTC) + const offsetMs = asIfUtcMs - utcGuessMs; + + // Gesucht: Zeitpunkt, der in der Zone die gewünschte lokale Zeit ergibt + return utcGuessMs - offsetMs; } + function render_finished(container) { uiu.empty(container); - uiu.el(container, 'h3', {}, ci18n('Finished Matches')); - - const matches = curt.matches.filter(m => calc_section(m) === 'finished'); - render_match_table(container, matches, true); + uiu.el(container, 'h3', 'section', ci18n('Finished Matches')); + + const matches = curt.matches.filter(m => calc_section(m) === 'finished').sort((a, b) => { + var a_ts = a.end_ts; + var b_ts = b.end_ts; + + if(!a_ts) { + a_ts = zoned_time_to_utc_timestamp(a.setup.scheduled_date, a.setup.scheduled_time_str, 'Europe/Berlin'); + } + if(!b_ts) { + b_ts = zoned_time_to_utc_timestamp(b.setup.scheduled_date, b.setup.scheduled_time_str, 'Europe/Berlin'); + } + return b_ts - a_ts + }); + render_match_table(container, matches, 'default', false, curt.tabletoperator_enabled); } function render_courts(container, style) { style = style || 'plain'; uiu.empty(container); + if(style === 'public') { + uiu.el(container, 'h2', {}, 'Aktuelle Spiele'); + } const table = uiu.el(container, 'table', 'match_table'); const tbody = uiu.el(table, 'tbody'); - for (const c of curt.courts) { + var resizable_rows = []; + + const locationById = Object.fromEntries( + curt.locations.map(l => [l._id, l]) + ); + + const params = new URLSearchParams(window.location.search); + const param_location = params.get("location"); + + + const courts = curt.courts.filter(c => { + if (!param_location) return true; + + const loc = locationById[c.location_id]; + + // KEINE Location → trotzdem anzeigen + if (!loc) return true; + + // Location vorhanden → muss matchen + return loc.name === param_location; + }); + + for (const c of courts) { const expected_section = 'court_' + c._id; const court_matches = curt.matches.filter(m => calc_section(m) === expected_section); - const tr = uiu.el(tbody, 'tr'); + const tr = uiu.el(tbody, 'tr', {class:"court_row", "data-court_id":c._id, "data-location_id":c.location_id} ); const rowspan = Math.max(1, court_matches.length); - uiu.el(tr, 'th', { - 'class': 'court_num', - style: ((style === 'public') ? 'padding-right: 0.5em' : ''), - rowspan, - title: c._id, - }, c.num); + //uiu.el(tr, 'th', { + // 'class': 'court_num', + // rowspan, + // title: c._id, + //}, c.num); + + //const court_number_td = uiu.el(tr, "td", 'court_number'); + //uiu.el(court_number_td, "div", "court_num", c.num); + if (court_matches.length === 0) { - uiu.el(tr, 'td', {colspan: 9}, ''); + render_empty_court_row(tr, c, style, true); } else { let i = 0; for (const cm of court_matches) { const my_tr = (i > 0) ? uiu.el(tbody, 'tr') : tr; - render_match_row(my_tr, cm, c, style); + resizable_rows.push(render_match_row(my_tr, cm, c, style)); i++; } } + + if(!(window.localStorage.getItem('show_location_courts_' + c.location_id) !== 'false')) { + tr.classList.add('do_not_show'); + } + } + + if(style === 'public') { + resize_table(resizable_rows, 0.98); + } +} + +function update_tables(location_id, enabled) { + // Alle Elemente mit dem passenden data-location_id Attribut finden + const elements = document.querySelectorAll(`[data-location_id="${location_id}"]`); + + elements.forEach(el => { + if (enabled === true) { + el.classList.remove('do_not_show'); + } else { + el.classList.add('do_not_show'); + } + }); +} + +function render_empty_court_row(tr, court, style, is_droppable) { + tr.setAttribute("data-style", style); + + if (!court) { + console.warn('court is not set!'); + + } + + const is_active = court.is_active; + + if(style != 'public') { + const lead_target_td = uiu.el(tr, 'td', {class: (is_active ? "droppable " : "inactive " ) + "actions", colspan: 1, "data-court_id":court._id, "data-state" : (is_active ? "droppable" : "inactive" )}, ''); + + if(is_active){ + lead_target_td.addEventListener("drop", drop); + lead_target_td.addEventListener("dragover", allowDrop); + } + + const court_number_td = uiu.el(tr, "td", {'class':'court_number', "data-court_id":court._id, "data-state" : (is_active ? "droppable" : "inactive")}); + if(is_active) { + create_court_button(court_number_td, 'court_num', 'inactivate_court', on_inactivate_court_button_click, court._id, court.num); + } else { + create_court_button(court_number_td, 'court_inactive', 'activate_court', on_activate_court_button_click, court._id, ''); + } + + const target_td = uiu.el(tr, 'td', {class: 'empty_element', colspan: 11, "data-court_id":court._id}, ''); + if(is_active) { + court_number_td.classList.add('droppable'); + target_td.classList.add('droppable'); + target_td.setAttribute('data-state', 'droppable') + + court_number_td.addEventListener("drop", drop); + court_number_td.addEventListener("dragover", allowDrop); + target_td.addEventListener("drop", drop); + target_td.addEventListener("dragover", allowDrop); + } else { + court_number_td.classList.add('inactive'); + target_td.classList.add('inactive'); + + target_td.setAttribute('data-state', 'inactive') + } + } else { + const court_number_td = uiu.el(tr, "td", {'class':'court_number', "data-court_id":court._id}); + if(is_active){ + uiu.el(court_number_td, "div", 'court_num', court.num); + } else { + uiu.el(court_number_td, "div", 'court_inactive', ""); + } + + const target_td = uiu.el(tr, 'td', {class: 'empty_element', colspan: 11, "data-court_id":court._id}, ''); + } +} + +function update_court(court) { + const tr = uiu.qs(`tr[data-court_id="${court._id}"]`); + if (!tr) { + return; + } + const match_id = tr.getAttribute('data-match_id'); + const style = tr.getAttribute("data-style"); + tr.innerHTML = ""; + if(match_id == null) { + render_empty_court_row(tr, court, style, true); + return; + } + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m || !m.setup || m.setup.now_on_court !== true || ['finished'].includes(m.setup.state)) { + tr.removeAttribute('data-match_id'); + render_empty_court_row(tr, court, style, true); + return; + } + render_match_row(tr, m, court, style); +} + + +function on_inactivate_court_button_click(e) { + const btn = e.target; + const court_id = btn.getAttribute('data-court_id'); + send({ + type: 'court_edit', + tournament_key: curt.key, + is_active: false, + court_id: court_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); +} + +function on_activate_court_button_click(e) { + const btn = e.target; + const court_id = btn.getAttribute('data-court_id'); + send({ + type: 'court_edit', + tournament_key: curt.key, + is_active: true, + court_id: court_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); +} + + +function create_court_button(targetEl, cssClass, title, listener, court_id, text) { + const btn = uiu.el(targetEl, 'div', { + 'class': cssClass, + 'title': ci18n(title), + 'data-court_id': court_id, + 'data-state': cssClass, + }, text); + btn.addEventListener('click', listener); +} + + +function allowDrop(ev) { + ev.preventDefault(); + ev.stopPropagation(); +} + +function validate_match_complete(match_id) { + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to call on court. ID: ' + JSON.stringify(match_id)); + return false; + } + + if (m.setup.teams[0].players.length == 0 || m.setup.teams[1].players.length == 0) { + cerror.silent("Match cannot be called one or more Teams are not set.") + return false; + } + return true; +} + +function drag(ev) { + const drag_source = ev.currentTarget || ev.target; + let match_id = drag_source && drag_source.getAttribute ? drag_source.getAttribute("data-match_id") : null; + if (validate_match_complete(match_id)) { + ev.dataTransfer.setData('text', match_id); + + for (const dropp_row of document.getElementsByClassName ("droppable")) { + dropp_row.classList.add("droppable_active"); + } + } +} + +function dragend(ev) { + for (const dropp_row of document.getElementsByClassName ("droppable")) { + dropp_row.classList.remove("droppable_active"); + } +} + +function drop(ev) { + ev.preventDefault(); + ev.stopPropagation(); + let match_id = ev.dataTransfer.getData('text'); + const drop_target = ev.currentTarget || ev.target; + const court_target = drop_target && drop_target.closest ? drop_target.closest('[data-court_id]') : drop_target; + const court_id = court_target && court_target.getAttribute ? court_target.getAttribute('data-court_id') : null; + if (validate_match_complete(match_id)) { + send({ + type: 'match_call_on_court', + court_id: court_id, + match_id: match_id, + tournament_key: curt.key, + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + + for (const dropp_row of document.getElementsByClassName("droppable")) { + dropp_row.setAttribute("class", "droppable"); + } } } @@ -612,6 +2449,7 @@ function render_edit(form, match) { required: 'required', value: setup.match_num || '', tabindex: 1, + disabled: 'disabled', }); uiu.el(metadata, 'span', 'match_label', 'Event:'); @@ -621,6 +2459,7 @@ function render_edit(form, match) { placeholder: ci18n('e.g. MX O55'), size: 10, value: setup.event_name || '', + disabled: 'disabled', }); uiu.el(metadata, 'span', 'match_label', 'Match:'); @@ -630,6 +2469,8 @@ function render_edit(form, match) { placeholder: ci18n('e.g. semi-finals'), size: 10, value: setup.match_name || '', + disabled: 'disabled', + style: 'width: 253px;', }); const start = uiu.el(edit_match_container, 'div'); @@ -641,6 +2482,8 @@ function render_edit(form, match) { title: 'Date in ISO8601 format, e.g. 2020-05-30', size: 6, value: setup.scheduled_date || '', + style: 'width: 238px;', + disabled: 'disabled', }); uiu.el(start, 'span', 'match_label', ci18n('Time:')); @@ -651,6 +2494,8 @@ function render_edit(form, match) { title: 'Time in 24 hour format, e.g. 09:23', size: 3, value: setup.scheduled_time_str || '', + style: 'width: 268px;', + disabled: 'disabled', }); const player_table = uiu.el(edit_match_container, 'table'); @@ -663,6 +2508,7 @@ function render_edit(form, match) { size: 3, name: 'team0player0nationality', value: player_names.team0player0.nationality || '', + disabled: 'disabled', }); uiu.el(t0p0td, 'input', { type: 'text', @@ -671,6 +2517,7 @@ function render_edit(form, match) { required: 'required', value: player_names.team0player0.firstname, tabindex: 20, + disabled: 'disabled', }); uiu.el(t0p0td, 'input', { type: 'text', @@ -679,6 +2526,7 @@ function render_edit(form, match) { required: 'required', value: player_names.team0player0.lastname, tabindex: 20, + disabled: 'disabled', }); const t0p1td = uiu.el(tr1, 'td'); uiu.el(t0p1td, 'input', { @@ -686,6 +2534,7 @@ function render_edit(form, match) { size: 3, name: 'team0player1nationality', value: player_names.team0player1.nationality || '', + disabled: 'disabled', }); uiu.el(t0p1td, 'input', { type: 'text', @@ -693,6 +2542,7 @@ function render_edit(form, match) { name: 'team0player1firstname', value: player_names.team0player1.firstname, tabindex: 21, + disabled: 'disabled', }); uiu.el(t0p1td, 'input', { type: 'text', @@ -701,6 +2551,7 @@ function render_edit(form, match) { placeholder: ci18n('(Singles)'), value: player_names.team0player1.lastname, tabindex: 21, + disabled: 'disabled', }); uiu.el(tr0, 'td', { @@ -714,6 +2565,7 @@ function render_edit(form, match) { size: 3, name: 'team1player0nationality', value: player_names.team1player0.nationality || '', + disabled: 'disabled', }); uiu.el(t1p0td, 'input', { type: 'text', @@ -722,6 +2574,7 @@ function render_edit(form, match) { required: 'required', value: player_names.team1player0.firstname, tabindex: 30, + disabled: 'disabled', }); uiu.el(t1p0td, 'input', { type: 'text', @@ -730,6 +2583,7 @@ function render_edit(form, match) { required: 'required', value: player_names.team1player0.lastname, tabindex: 30, + disabled: 'disabled', }); const t1p1td = uiu.el(tr1, 'td'); uiu.el(t1p1td, 'input', { @@ -737,6 +2591,7 @@ function render_edit(form, match) { size: 3, name: 'team1player1nationality', value: player_names.team1player1.nationality || '', + disabled: 'disabled', }); uiu.el(t1p1td, 'input', { type: 'text', @@ -744,6 +2599,7 @@ function render_edit(form, match) { name: 'team1player1firstname', value: player_names.team1player1.firstname, tabindex: 31, + disabled: 'disabled', }); uiu.el(t1p1td, 'input', { type: 'text', @@ -752,6 +2608,7 @@ function render_edit(form, match) { placeholder: ci18n('(Singles)'), value: player_names.team1player1.lastname, tabindex: 31, + disabled: 'disabled', }); if (curt.is_team) { @@ -783,6 +2640,28 @@ function render_edit(form, match) { const assigned = uiu.el(edit_match_container, 'div', { style: 'margin-top: 1em', }); + uiu.el(assigned, 'span', 'match_label', ci18n('match:edit:preparation')); + const preparation_select = uiu.el(assigned, 'select', { + name: 'preparation_location_id', + size: 1, + }); + uiu.el(preparation_select, 'option', { + value: '', + selected: setup.state === 'preparation' ? undefined : 'selected', + }, ci18n('match:edit:not_in_preparation')); + if (curt && curt.locations) { + for (const location of curt.locations) { + const attrs = { + value: location._id, + }; + if (setup.state === 'preparation' && location._id === setup.location_id) { + attrs.selected = 'selected'; + } + uiu.el(preparation_select, 'option', attrs, ci18n('match:edit:in_preparation_for', { + location_name: location.name || location._id, + })); + } + } uiu.el(assigned, 'span', 'match_label', 'Court:'); const court_select = uiu.el(assigned, 'select', { 'class': 'court_selector', @@ -813,6 +2692,9 @@ function render_edit(form, match) { if (setup.now_on_court) { now_on_court_attrs.checked = 'checked'; } + if (setup.teams[0].players.length < 1 && setup.teams[1].players.length < 1) { + now_on_court_attrs.disabled = true; + } uiu.el(now_on_court_label, 'input', now_on_court_attrs); uiu.el(now_on_court_label, 'span', 'match_label', ci18n('match:edit:now_on_court')); @@ -827,7 +2709,6 @@ function render_edit(form, match) { name: 'umpire_name', size: 1, }); - render_umpire_options(umpire_select, setup.umpire_name); // Service judge uiu.el(tos_container, 'span', { @@ -838,7 +2719,64 @@ function render_edit(form, match) { name: 'service_judge_name', size: 1, }); - render_umpire_options(service_judge_select, setup.service_judge_name, true); + const show_all_officials_label = uiu.el(tos_container, 'label', { + style: 'margin-left: 1em;', + }); + const show_all_officials_checkbox = uiu.el(show_all_officials_label, 'input', { + type: 'checkbox', + name: 'show_all_officials', + }); + show_all_officials_label.appendChild(document.createTextNode(' ' + ci18n('match:edit:show_all_officials'))); + let current_umpire_value = (setup.umpire && setup.umpire.name) ? setup.umpire.name : ''; + let current_service_judge_value = (setup.service_judge && setup.service_judge.name) ? setup.service_judge.name : ''; + const rerender_official_selects = () => { + const umpire_value = current_umpire_value; + if (!umpire_value) { + current_service_judge_value = ''; + } + const service_judge_value = current_service_judge_value; + render_umpire_options( + umpire_select, + umpire_value, + false, + !!show_all_officials_checkbox.checked, + service_judge_value, + service_judge_value + ); + render_umpire_options( + service_judge_select, + service_judge_value, + true, + !!show_all_officials_checkbox.checked, + umpire_value, + umpire_value + ); + service_judge_select.disabled = !umpire_value; + }; + show_all_officials_checkbox.addEventListener('change', rerender_official_selects); + umpire_select.addEventListener('change', () => { + const previous_umpire_value = current_umpire_value; + const previous_service_judge_value = current_service_judge_value; + current_umpire_value = umpire_select.value; + if (current_umpire_value && current_umpire_value === previous_service_judge_value) { + current_service_judge_value = previous_umpire_value; + } else if (current_umpire_value && current_umpire_value === current_service_judge_value) { + current_service_judge_value = ''; + } + rerender_official_selects(); + }); + service_judge_select.addEventListener('change', () => { + const previous_umpire_value = current_umpire_value; + const previous_service_judge_value = current_service_judge_value; + current_service_judge_value = service_judge_select.value; + if (current_service_judge_value && current_service_judge_value === previous_umpire_value) { + current_umpire_value = previous_service_judge_value; + } else if (current_service_judge_value && current_service_judge_value === current_umpire_value) { + current_umpire_value = ''; + } + rerender_official_selects(); + }); + rerender_official_selects(); render_override_colors(edit_match_container, setup); } @@ -903,24 +2841,223 @@ function update_override_color_checkbox(e) { } } -function render_umpire_options(select, curval, is_service_judge) { +function build_official_select_entries(tournament, is_service_judge, show_all_officials) { + const primary_role = is_service_judge ? 'service_judge' : 'umpire'; + const secondary_role = is_service_judge ? 'umpire' : 'service_judge'; + const role_label = (role) => role === 'umpire' ? ci18n('Umpire') : ci18n('Service judge'); + const with_role_label = (official, role) => `${official.name} (${role_label(role)})`; + const entries = []; + const append_separator = (label) => { + entries.push({ + type: 'separator', + label: `--- ${label} ---` + }); + }; + const is_waiting_list_label = (label) => + label === ci18n('Waiting list umpire') || label === ci18n('Waiting list service judge'); + const append_options = (items, role, secondary_mode = false) => { + for (const official of items) { + entries.push({ + type: 'option', + value: official.name, + label: secondary_mode ? with_role_label(official, role) : official.name + }); + } + }; + if (show_all_officials) { + const visible_official_ids = new Set(); + const mark_visible = (official) => { + if (official && official._id) { + visible_official_ids.add(official._id); + } + }; + const sort_by_name = (items) => [...items].sort((a, b) => cbts_utils.natcmp(a.name || '', b.name || '')); + const sort_by_wait = (items, field) => [...items].sort((a, b) => { + const ts_a = a[field] || 0; + const ts_b = b[field] || 0; + if (ts_a !== ts_b) return ts_a - ts_b; + return cbts_utils.natcmp(a.name || '', b.name || ''); + }); + const all_officials = tournament.umpires || []; + const should_render_in_lower_lists = (official) => !visible_official_ids.has(official._id); + const primary_wait_field = `${primary_role}_wait`; + const secondary_wait_field = `${secondary_role}_wait`; + const primary_pause_field = `${primary_role}_pause`; + const secondary_pause_field = `${secondary_role}_pause`; + const primary_manual_pause_field = `${primary_role}_manual_pause`; + const secondary_manual_pause_field = `${secondary_role}_manual_pause`; + const preparation_matches = [...(curt.matches || [])] + .filter((match) => (match.setup || {}).state === 'preparation') + .sort((a, b) => (a.setup?.preparation_call_timestamp || 0) - (b.setup?.preparation_call_timestamp || 0)); + const assigned_matches = [...(curt.matches || [])] + .filter((match) => { + const setup = match.setup || {}; + return setup.state !== 'preparation' + && !['oncourt', 'blocked', 'finished'].includes(setup.state) + && ((setup.umpire && setup.umpire._id) || (setup.service_judge && setup.service_judge._id)); + }) + .sort((a, b) => cbts_utils.natcmp(String(a.setup?.match_num || ''), String(b.setup?.match_num || ''))); + preparation_matches.forEach((match) => { + mark_visible(match.setup && match.setup.umpire); + mark_visible(match.setup && match.setup.service_judge); + }); + assigned_matches.forEach((match) => { + mark_visible(match.setup && match.setup.umpire); + mark_visible(match.setup && match.setup.service_judge); + }); + for (const official of all_officials) { + if (official.umpire_on_court != null || official.service_judge_on_court != null) { + mark_visible(official); + } + } + const primary_wait_items = sort_by_wait( + all_officials.filter((u) => u[primary_wait_field] != null && should_render_in_lower_lists(u)), + primary_wait_field + ); + const secondary_wait_items = sort_by_wait( + all_officials.filter((u) => u[secondary_wait_field] != null && should_render_in_lower_lists(u)), + secondary_wait_field + ); + const primary_pause_items = sort_by_wait( + all_officials.filter((u) => (u[primary_pause_field] != null || u[primary_manual_pause_field] != null) && should_render_in_lower_lists(u)), + primary_pause_field + ); + const secondary_pause_items = sort_by_wait( + all_officials.filter((u) => (u[secondary_pause_field] != null || u[secondary_manual_pause_field] != null) && should_render_in_lower_lists(u)), + secondary_pause_field + ); + const preparation_primary = sort_by_name(preparation_matches.map((match) => match.setup && match.setup[primary_role]).filter(Boolean)); + const preparation_secondary = sort_by_name(preparation_matches.map((match) => match.setup && match.setup[secondary_role]).filter(Boolean)); + const assigned_primary = sort_by_name(assigned_matches.map((match) => match.setup && match.setup[primary_role]).filter(Boolean)); + const assigned_secondary = sort_by_name(assigned_matches.map((match) => match.setup && match.setup[secondary_role]).filter(Boolean)); + const inactive_primary = sort_by_wait( + all_officials.filter((u) => u.inactive_list != null && should_render_in_lower_lists(u) && !(u.is_service_judge && !u.is_umpire)), + 'inactive_list' + ); + const inactive_secondary = sort_by_wait( + all_officials.filter((u) => u.inactive_list != null && should_render_in_lower_lists(u) && u.is_service_judge && !u.is_umpire), + 'inactive_list' + ); + const fallback_inactive_officials = all_officials + .filter((u) => !visible_official_ids.has(u._id)) + .filter((u) => u.umpire_wait == null && u.service_judge_wait == null && u.umpire_pause == null && u.service_judge_pause == null && u.umpire_manual_pause == null && u.service_judge_manual_pause == null && u.inactive_list == null) + .sort((a, b) => cbts_utils.natcmp(a.name || '', b.name || '')); + fallback_inactive_officials.forEach((official) => { + if (official.is_umpire && !official.is_service_judge) { + inactive_primary.push(official); + return; + } + if (official.is_service_judge && !official.is_umpire) { + inactive_secondary.push(official); + return; + } + inactive_primary.push(official); + }); + const on_court_primary = sort_by_name(all_officials.filter((u) => u[`${primary_role}_on_court`] != null)); + const on_court_secondary = sort_by_name(all_officials.filter((u) => u[`${secondary_role}_on_court`] != null)); + const sections = [ + { label: primary_role === 'umpire' ? ci18n('Waiting list umpire') : ci18n('Waiting list service judge'), items: primary_wait_items, role: primary_role, secondary_mode: false }, + { label: secondary_role === 'umpire' ? ci18n('Waiting list umpire') : ci18n('Waiting list service judge'), items: secondary_wait_items, role: secondary_role, secondary_mode: true }, + { label: ci18n('Currently on break:') + ' ' + role_label(primary_role), items: primary_pause_items, role: primary_role, secondary_mode: false }, + { label: ci18n('Currently on break:') + ' ' + role_label(secondary_role), items: secondary_pause_items, role: secondary_role, secondary_mode: true }, + { label: ci18n('Assigned to a match:'), items: assigned_primary, role: primary_role, secondary_mode: false }, + { label: ci18n('Assigned to a match:'), items: assigned_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n('Not available:'), items: inactive_primary, role: primary_role, secondary_mode: false }, + { label: ci18n('Not available:'), items: inactive_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n('In preparation:'), items: preparation_primary, role: primary_role, secondary_mode: false }, + { label: ci18n('In preparation:'), items: preparation_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n('On court:'), items: on_court_primary, role: primary_role, secondary_mode: false }, + { label: ci18n('On court:'), items: on_court_secondary, role: secondary_role, secondary_mode: true }, + ]; + let rendered_any = false; + sections.forEach((section) => { + if (!section.items.length) return; + if (rendered_any || !is_waiting_list_label(section.label)) { + append_separator(section.label.replace(/:$/, '')); + } + append_options(section.items, section.role, section.secondary_mode); + rendered_any = true; + }); + return entries; + } + const primary_wait_field = is_service_judge ? 'service_judge_wait' : 'umpire_wait'; + const secondary_wait_field = is_service_judge ? 'umpire_wait' : 'service_judge_wait'; + const sort_wait_list = (wait_field) => [...(tournament.umpires || [])] + .filter((u) => u[wait_field] != null) + .sort((a, b) => { + const ts_a = a[wait_field] || 0; + const ts_b = b[wait_field] || 0; + if (ts_a !== ts_b) return ts_a - ts_b; + return cbts_utils.natcmp(a.name || '', b.name || ''); + }); + const officials = [ + ...sort_wait_list(primary_wait_field).map((u) => ({ official: u, label: u.name })), + ...sort_wait_list(secondary_wait_field).map((u) => ({ official: u, label: with_role_label(u, secondary_wait_field === 'umpire_wait' ? 'umpire' : 'service_judge') })) + ]; + const primary_count = sort_wait_list(primary_wait_field).length; + const secondary_label = secondary_wait_field === 'umpire_wait' + ? '--- ' + ci18n('Waiting list umpire') + ' ---' + : '--- ' + ci18n('Waiting list service judge') + ' ---'; + for (const [index, entry] of officials.entries()) { + if (index === primary_count && officials.length > primary_count) { + entries.push({ + type: 'separator', + label: secondary_label + }); + } + entries.push({ + type: 'option', + value: entry.official.name, + label: entry.label + }); + } + return entries; +} + +function render_umpire_options(select, curval, is_service_judge, show_all_officials, disabled_value, swap_value) { uiu.empty(select); uiu.el(select, 'option', { value: '', style: 'font-style: italic;', }, is_service_judge ? ci18n('No service judge') : ci18n('No umpire')); - for (const u of curt.umpires) { + const entries = build_official_select_entries(curt, is_service_judge, show_all_officials); + const has_option = (value) => !!value && entries.some((entry) => entry.type === 'option' && entry.value === value); + const has_current_option = has_option(curval); + if (!!curval && !has_current_option) { + uiu.el(select, 'option', { + value: curval, + selected: 'selected', + }, curval); + } + if (!!swap_value && swap_value !== curval && !has_option(swap_value)) { + uiu.el(select, 'option', { + value: swap_value, + }, `${swap_value} (${ci18n('match:edit:swap_hint')})`); + } + for (const entry of entries) { + if (entry.type === 'separator') { + uiu.el(select, 'option', { + value: '', + disabled: 'disabled', + style: 'font-style: italic;', + }, entry.label); + continue; + } const attrs = { - value: u.name, + value: entry.value, }; - if (u.name === curval) { + if (disabled_value && entry.value === disabled_value && entry.value !== curval && entry.value !== swap_value) { + attrs.disabled = 'disabled'; + } + if (entry.value === curval) { attrs.selected = 'selected'; } - uiu.el(select, 'option', attrs, u.name); + uiu.el(select, 'option', attrs, entry.label); } } function render_create(container) { + /* uiu.empty(container); const form = uiu.el(container, 'form'); @@ -948,10 +3085,13 @@ function render_create(container) { render_create(container); }); }); + */ } return { + add_match, calc_section, + cmp_match_order: cmp_scheduled_match_order, prepare_render, render_create, render_finished, @@ -959,7 +3099,15 @@ return { render_courts, render_umpire_options, render_upcoming_matches, + update_court, update_match_score, + update_match, + remove_match_from_gui, + update_players, + create_timer, + update_tables, + _build_official_select_entries: build_official_select_entries, + _format_participant_dependency }; })(); @@ -975,6 +3123,7 @@ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var countries = require('./countries'); var crouting = require('./crouting'); var ctournament = require('./ctournament'); + var ctabletoperator = require('./ctabletoperator'); var form_utils = require('../bup/js/form_utils'); var uiu = require('../bup/js/uiu'); var utils = require('../bup/js/utils'); @@ -983,8 +3132,10 @@ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var i18n = require('../bup/js/i18n'); var i18n_de = require('../bup/js/i18n_de'); var i18n_en = require('../bup/js/i18n_en'); + var match_scoring = require('./match_scoring'); var printing = require('../bup/js/printing'); var settings = require('../bup/js/settings'); + var timer = require('../bup/js/timer'); module.exports = cmatch; } diff --git a/static/js/cmatch_official_select_helpers.js b/static/js/cmatch_official_select_helpers.js new file mode 100644 index 0000000..79527b2 --- /dev/null +++ b/static/js/cmatch_official_select_helpers.js @@ -0,0 +1,181 @@ +'use strict'; + +var cmatch_official_select_helpers = (function() { + function build_official_select_entries(tournament, is_service_judge, show_all_officials, deps) { + const { ci18n_fn, natcmp_fn } = deps; + const primary_role = is_service_judge ? 'service_judge' : 'umpire'; + const secondary_role = is_service_judge ? 'umpire' : 'service_judge'; + const role_label = (role) => role === 'umpire' ? ci18n_fn('Umpire') : ci18n_fn('Service judge'); + const with_role_label = (official, role) => `${official.name} (${role_label(role)})`; + const entries = []; + const append_separator = (label) => { + entries.push({ type: 'separator', label: `--- ${label} ---` }); + }; + const is_waiting_list_label = (label) => + label === ci18n_fn('Waiting list umpire') || label === ci18n_fn('Waiting list service judge'); + const append_options = (items, role, secondary_mode = false) => { + for (const official of items) { + entries.push({ + type: 'option', + value: official.name, + label: secondary_mode ? with_role_label(official, role) : official.name + }); + } + }; + const sort_by_name = (items) => [...items].sort((a, b) => natcmp_fn(a.name || '', b.name || '')); + const sort_by_wait = (items, field) => [...items].sort((a, b) => { + const ts_a = a[field] || 0; + const ts_b = b[field] || 0; + if (ts_a !== ts_b) return ts_a - ts_b; + return natcmp_fn(a.name || '', b.name || ''); + }); + + if (show_all_officials) { + const visible_official_ids = new Set(); + const mark_visible = (official) => { + if (official && official._id) { + visible_official_ids.add(official._id); + } + }; + const all_officials = tournament.umpires || []; + const preparation_matches = [...(tournament.matches || [])] + .filter((match) => (match.setup || {}).state === 'preparation') + .sort((a, b) => (a.setup?.preparation_call_timestamp || 0) - (b.setup?.preparation_call_timestamp || 0)); + const assigned_matches = [...(tournament.matches || [])] + .filter((match) => { + const setup = match.setup || {}; + return setup.state !== 'preparation' + && !['oncourt', 'blocked', 'finished'].includes(setup.state) + && ((setup.umpire && setup.umpire._id) || (setup.service_judge && setup.service_judge._id)); + }) + .sort((a, b) => natcmp_fn(String(a.setup?.match_num || ''), String(b.setup?.match_num || ''))); + preparation_matches.forEach((match) => { + mark_visible(match.setup && match.setup.umpire); + mark_visible(match.setup && match.setup.service_judge); + }); + assigned_matches.forEach((match) => { + mark_visible(match.setup && match.setup.umpire); + mark_visible(match.setup && match.setup.service_judge); + }); + for (const official of all_officials) { + if (official.umpire_on_court != null || official.service_judge_on_court != null) { + mark_visible(official); + } + } + const should_render_in_lower_lists = (official) => !visible_official_ids.has(official._id); + const primary_wait_field = `${primary_role}_wait`; + const secondary_wait_field = `${secondary_role}_wait`; + const primary_pause_field = `${primary_role}_pause`; + const secondary_pause_field = `${secondary_role}_pause`; + const primary_manual_pause_field = `${primary_role}_manual_pause`; + const secondary_manual_pause_field = `${secondary_role}_manual_pause`; + const primary_wait_items = sort_by_wait( + all_officials.filter((u) => u[primary_wait_field] != null && should_render_in_lower_lists(u)), + primary_wait_field + ); + const secondary_wait_items = sort_by_wait( + all_officials.filter((u) => u[secondary_wait_field] != null && should_render_in_lower_lists(u)), + secondary_wait_field + ); + const primary_pause_items = sort_by_wait( + all_officials.filter((u) => (u[primary_pause_field] != null || u[primary_manual_pause_field] != null) && should_render_in_lower_lists(u)), + primary_pause_field + ); + const secondary_pause_items = sort_by_wait( + all_officials.filter((u) => (u[secondary_pause_field] != null || u[secondary_manual_pause_field] != null) && should_render_in_lower_lists(u)), + secondary_pause_field + ); + const preparation_primary = sort_by_name(preparation_matches.map((match) => match.setup && match.setup[primary_role]).filter(Boolean)); + const preparation_secondary = sort_by_name(preparation_matches.map((match) => match.setup && match.setup[secondary_role]).filter(Boolean)); + const assigned_primary = sort_by_name(assigned_matches.map((match) => match.setup && match.setup[primary_role]).filter(Boolean)); + const assigned_secondary = sort_by_name(assigned_matches.map((match) => match.setup && match.setup[secondary_role]).filter(Boolean)); + const inactive_primary = sort_by_wait( + all_officials.filter((u) => u.inactive_list != null && should_render_in_lower_lists(u) && !(u.is_service_judge && !u.is_umpire)), + 'inactive_list' + ); + const inactive_secondary = sort_by_wait( + all_officials.filter((u) => u.inactive_list != null && should_render_in_lower_lists(u) && u.is_service_judge && !u.is_umpire), + 'inactive_list' + ); + const fallback_inactive_officials = all_officials + .filter((u) => !visible_official_ids.has(u._id)) + .filter((u) => u.umpire_wait == null && u.service_judge_wait == null && u.umpire_pause == null && u.service_judge_pause == null && u.umpire_manual_pause == null && u.service_judge_manual_pause == null && u.inactive_list == null) + .sort((a, b) => natcmp_fn(a.name || '', b.name || '')); + fallback_inactive_officials.forEach((official) => { + if (official.is_umpire && !official.is_service_judge) { + inactive_primary.push(official); + return; + } + if (official.is_service_judge && !official.is_umpire) { + inactive_secondary.push(official); + return; + } + inactive_primary.push(official); + }); + const on_court_primary = sort_by_name(all_officials.filter((u) => u[`${primary_role}_on_court`] != null)); + const on_court_secondary = sort_by_name(all_officials.filter((u) => u[`${secondary_role}_on_court`] != null)); + const sections = [ + { label: primary_role === 'umpire' ? ci18n_fn('Waiting list umpire') : ci18n_fn('Waiting list service judge'), items: primary_wait_items, role: primary_role, secondary_mode: false }, + { label: secondary_role === 'umpire' ? ci18n_fn('Waiting list umpire') : ci18n_fn('Waiting list service judge'), items: secondary_wait_items, role: secondary_role, secondary_mode: true }, + { label: ci18n_fn('Currently on break:') + ' ' + role_label(primary_role), items: primary_pause_items, role: primary_role, secondary_mode: false }, + { label: ci18n_fn('Currently on break:') + ' ' + role_label(secondary_role), items: secondary_pause_items, role: secondary_role, secondary_mode: true }, + { label: ci18n_fn('Assigned to a match:'), items: assigned_primary, role: primary_role, secondary_mode: false }, + { label: ci18n_fn('Assigned to a match:'), items: assigned_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n_fn('Not available:'), items: inactive_primary, role: primary_role, secondary_mode: false }, + { label: ci18n_fn('Not available:'), items: inactive_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n_fn('In preparation:'), items: preparation_primary, role: primary_role, secondary_mode: false }, + { label: ci18n_fn('In preparation:'), items: preparation_secondary, role: secondary_role, secondary_mode: true }, + { label: ci18n_fn('On court:'), items: on_court_primary, role: primary_role, secondary_mode: false }, + { label: ci18n_fn('On court:'), items: on_court_secondary, role: secondary_role, secondary_mode: true }, + ]; + let rendered_any = false; + sections.forEach((section) => { + if (!section.items.length) return; + if (rendered_any || !is_waiting_list_label(section.label)) { + append_separator(section.label.replace(/:$/, '')); + } + append_options(section.items, section.role, section.secondary_mode); + rendered_any = true; + }); + return entries; + } + + const primary_wait_field = is_service_judge ? 'service_judge_wait' : 'umpire_wait'; + const secondary_wait_field = is_service_judge ? 'umpire_wait' : 'service_judge_wait'; + const sort_wait_list = (wait_field) => [...(tournament.umpires || [])] + .filter((u) => u[wait_field] != null) + .sort((a, b) => { + const ts_a = a[wait_field] || 0; + const ts_b = b[wait_field] || 0; + if (ts_a !== ts_b) return ts_a - ts_b; + return natcmp_fn(a.name || '', b.name || ''); + }); + const officials = [ + ...sort_wait_list(primary_wait_field).map((u) => ({ official: u, label: u.name })), + ...sort_wait_list(secondary_wait_field).map((u) => ({ official: u, label: with_role_label(u, secondary_wait_field === 'umpire_wait' ? 'umpire' : 'service_judge') })) + ]; + const primary_count = sort_wait_list(primary_wait_field).length; + const secondary_label = secondary_wait_field === 'umpire_wait' + ? '--- ' + ci18n_fn('Waiting list umpire') + ' ---' + : '--- ' + ci18n_fn('Waiting list service judge') + ' ---'; + for (const [index, entry] of officials.entries()) { + if (index === primary_count && officials.length > primary_count) { + entries.push({ type: 'separator', label: secondary_label }); + } + entries.push({ + type: 'option', + value: entry.official.name, + label: entry.label + }); + } + return entries; + } + + return { + build_official_select_entries + }; +})(); + +if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { + module.exports = cmatch_official_select_helpers; +} diff --git a/static/js/conn_ui.js b/static/js/conn_ui.js index 7415efe..1b91b4a 100644 --- a/static/js/conn_ui.js +++ b/static/js/conn_ui.js @@ -16,7 +16,8 @@ function ui_on_status(status) { text = 'Unsupported status: ' + status.code; } - uiu.text_qs('.status', text); + ctournament.bts_status_changed({ val: { status: status.code, message: text } }) + uiu.visible_qs('.connecting', (status.code === 'connecting') || (status.code === 'waiting')); if (status.code === 'connected') { @@ -56,6 +57,7 @@ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var ci18n = require('./ci18n'); var conn = require('./conn'); var crouting = require('./crouting'); + var ctournament = require('./ctournament'); var uiu = null; // UI only module.exports = conn_ui; diff --git a/static/js/ctabletoperator.js b/static/js/ctabletoperator.js new file mode 100644 index 0000000..b44082f --- /dev/null +++ b/static/js/ctabletoperator.js @@ -0,0 +1,199 @@ +'use strict'; + +var ctabletoperator = (function() { + +function render_unassigned(container) { + uiu.empty(container); + uiu.el(container, 'h3', {}, ci18n('tabletoperator:unassigned')); + const unassigned_tabletoperators = curt.tabletoperators.filter(m => m.court == null); + + unassigned_tabletoperators.sort((a, b) => { + return a.start_ts - b.start_ts; + }); + + const tableoperator_content = uiu.el(container, 'div', 'unassigned_tableoperators_content'); + render_tabletoperator_table(tableoperator_content, unassigned_tabletoperators); + render_tabletoperator_formular(container); +} + +function render_tabletoperator_table(container, tabletoperators) { + + const table = uiu.el(container, 'table', 'tabletoperators_table'); + //render_tabletoperator_table_header(table); + const tbody = uiu.el(table, 'tbody'); + + tabletoperators.forEach((t, index) => { + + const tr = uiu.el(tbody, 'tr'); + render_tabletoperator_row(tr, t, index === 0, index >= (tabletoperators.length - 1)); + }); +} + +function render_tabletoperator_table_header(table) { + const thead = uiu.el(table, 'thead'); + const title_tr = uiu.el(thead, 'tr'); + uiu.el(title_tr, 'th', {}, ci18n('tabletoperator:name')); +} +function render_tabletoperator_row(tr, tabletoperator, is_fist_entry, is_last_entry) { + const to = tabletoperator.tabletoperator; + const to_td = uiu.el(tr, 'td'); + const tablet_div = uiu.el(to_td, 'div', 'tablet_operator', ''); + uiu.el(tablet_div, 'div', 'tablet', ''); + const operators_div = uiu.el(tablet_div, 'div', 'operators'); + const person_div = uiu.el(operators_div, 'div', 'person'); + uiu.el(person_div, 'span', 'match_no_umpire', to[0].name); + if (to.length > 1) { + + uiu.el(person_div, 'span', 'match_no_umpire', ' \u200B/ '); + const person2_div = uiu.el(operators_div, 'div', 'person'); + uiu.el(person2_div, 'span', 'match_no_umpire', to[1].name ); + + } + + const court = curt.courts_by_id[tabletoperator.played_on_court]; + const court_td = uiu.el(tr, 'td', 'court_played'); + + + uiu.el(court_td, 'div', court ? 'court_history' : '', court ? court.num : ''); + + if (tabletoperator.court == null) { + const buttonbar = uiu.el(tr, 'td'); + if(!is_fist_entry) { + create_tabletoperator_button(buttonbar, 'vlink tabletoperator_move_up_button', 'tabletoperator:move_up', on_move_up_button_click, tabletoperator._id); + } + } + + if (tabletoperator.court == null) { + const buttonbar = uiu.el(tr, 'td'); + if(! is_last_entry) { + create_tabletoperator_button(buttonbar, 'vlink tabletoperator_move_down_button', 'tabletoperator:move_down', on_move_down_button_click, tabletoperator._id); + } + } + + if (tabletoperator.court == null) { + const buttonbar = uiu.el(tr, 'td'); + create_tabletoperator_button(buttonbar, 'vlink tabletoperator_remove_button', 'tabletoperator:remove', on_remove_from_list_button_click, tabletoperator._id); + } +} + +function on_move_up_button_click(e) { + const to = fetchTabletOperatorFromEvent(e); + if (to != null) { + send({ + type: 'tabletoperator_move_up', + tournament_key: curt.key, + tabletoperator: to, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +function on_move_down_button_click(e) { + const to = fetchTabletOperatorFromEvent(e); + if (to != null) { + send({ + type: 'tabletoperator_move_down', + tournament_key: curt.key, + tabletoperator: to, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +function on_remove_from_list_button_click(e) { + const to = fetchTabletOperatorFromEvent(e); + if (to != null) { + send({ + type: 'tabletoperator_remove', + tournament_key: curt.key, + tabletoperator: to, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +function fetchTabletOperatorFromEvent(e) { + const btn = e.target; + const to_id = btn.getAttribute('data-tabletoperator_id'); + const to = utils.find(curt.tabletoperators, to => to._id === to_id); + if (!to) { + cerror.silent('Tabletoperator ' + to_id + ' konnte nicht gefunden werden'); + return null; + } else { + return to; + } +} +function create_tabletoperator_button(targetEl, cssClass, title, listener, tabletoperatorID) { + const btn = uiu.el(targetEl, 'div', { + 'class': cssClass, + 'title': ci18n(title), + 'data-tabletoperator_id': tabletoperatorID, + }); + btn.addEventListener('click', listener); +} + +function render_tabletoperator_formular(target) { + const announcements = uiu.el(target, 'div', '_tabletoperator_container'); + const form = uiu.el(announcements, 'form'); + uiu.el(form, 'input', { + type: 'input', + class: 'tabletoperator_add_custom_input', + id: 'tabletoperator_name', + name: 'tabletoperator_name' + }); + const btp_fetch_btn = uiu.el(form, 'button', { + class: 'vlink tabletoperator_add_custom_button', + role: 'submit', + }); + form_utils.onsubmit(form, function (d) { + add_to_tabletoperator(null, null, d.tabletoperator_name) + }); +} + +function add_to_tabletoperator(match, team_num, tabletoperator_name) { + if (match != null || tabletoperator_name) { + send({ + type: 'tabletoperator_add', + tournament_key: curt.key, + team_id: team_num, + tabletoperator_name: tabletoperator_name, + match: match, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } +} + +return { + render_unassigned, + add_to_tabletoperator +}; + +})(); + +/*@DEV*/ +if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { + var cflags = require('./cflags'); + var ci18n = require('./ci18n.js'); + var change = require('./change.js'); + var cmatch = require('./cmatch.js'); + var crouting = require('./crouting.js'); + var ctournament = require('./ctournament.js'); + var toprow = require('./toprow.js'); + var uiu = require('../bup/js/uiu.js'); + var utils = require('../bup/js/utils.js'); + + module.exports = ctabletoperator; +} +/*/@DEV*/ diff --git a/static/js/cto_stats.js b/static/js/cto_stats.js index 2873542..90bebf0 100644 --- a/static/js/cto_stats.js +++ b/static/js/cto_stats.js @@ -22,9 +22,6 @@ function ui_to_stats() { uiu.el(main, 'h2', {}, ci18n('to_stats:header')); uiu.el(main, 'h3', {}, curt.name || curt.key); - // TODO add dates + location here - console.log(curt.matches) - const table = uiu.el(main, 'table', 'table-outlined'); const thead = uiu.el(table, 'thead'); const thead_tr = uiu.el(thead, 'tr'); diff --git a/static/js/ctournament.js b/static/js/ctournament.js index 2a48c34..a840aef 100644 --- a/static/js/ctournament.js +++ b/static/js/ctournament.js @@ -1,827 +1,7096 @@ 'use strict'; var curt; // current tournament +let current_view = null; +let scoring_formats_main = null; +let live_settings_status_el = null; +let live_settings_pending_requests = 0; +let self_check_in_chip_fit_cache = Object.create(null); +let self_check_in_layout_fit_cache = new Map(); +let self_check_in_resize_frame = null; +let self_check_in_measure_probe = null; +let self_check_in_fit_scheduled = false; +let self_check_in_fit_roots = new Set(); +let self_check_in_called_overlay_timeout = null; +let skip_next_official_list_move = null; +let official_drag_image_el = null; +let official_drag_active = false; +let official_drag_refresh_pending = false; +let pending_official_role_overrides = new Map(); +let preparation_selection_request_inflight = false; +let preparation_selection_request_pending = false; +let btp_next_fetch_countdown_interval = null; +let speech_output_badge_listener_registered = false; +const ANNOUNCEMENT_SPEECH_CHECK_STATE_STORAGE_KEY = 'bts_announcement_speech_check_state'; var ctournament = (function() { -function _route_single(rex, func, handler) { - if (!handler) { - handler = change.default_handler(func); + function _route_single(rex, func, handler) { + if (!handler) { + handler = change.default_handler(func); + } + + crouting.register(rex, function (m) { + switch_tournament(m[1], func); + }, handler); + } + + function switch_tournament(tournament_key, success_cb) { + send({ + type: 'tournament_get', + key: tournament_key, + }, function (err, response) { + if (err) { + return cerror.net(err); + } + + curt = response.tournament; + preparation_selection_request_inflight = false; + preparation_selection_request_pending = false; + curt.location_preparation_selection_by_location_id = {}; + if (curt.language && curt.language !== 'auto') { + ci18n.switch_language(curt.language); + } + success_cb(); + }); + } + + function ui_create() { + const main = uiu.qs('.main'); + + uiu.empty(main); + const form = uiu.el(main, 'form'); + uiu.el(form, 'h2', 'edit', ci18n('Create tournament')); + const id_label = uiu.el(form, 'label', {}, ci18n('create:id:label')); + const key_input = uiu.el(id_label, 'input', { + type: 'text', + name: 'key', + autofocus: 'autofocus', + required: 'required', + pattern: '^[a-z0-9]+$', + }); + uiu.el(form, 'button', { + role: 'submit', + }, ci18n('Create tournament')); + key_input.focus(); + + form_utils.onsubmit(form, function (data) { + send({ + type: 'create_tournament', + key: data.key, + }, function (err) { + if (err) return cerror.net(err); + + uiu.remove(form); + switch_tournament(data.key, ui_show); + }); + }); + } + + function ui_list() { + crouting.set('t/'); + toprow.set([{ + label: ci18n('Tournaments'), + func: ui_list, + }]); + + send({ + type: 'tournament_list', + }, function (err, response) { + if (err) { + return cerror.net(err); + } + list_show(response.tournaments); + }); + } + + function set_pending_official_role_override(official_id, values) { + if (!official_id) return; + pending_official_role_overrides.set(official_id, { + is_umpire: !!values.is_umpire, + is_service_judge: !!values.is_service_judge + }); + } + + function apply_pending_official_role_override(official) { + if (!official || !official._id) return official; + const pending = pending_official_role_overrides.get(official._id); + if (!pending) return official; + if ( + !!official.is_umpire === pending.is_umpire && + !!official.is_service_judge === pending.is_service_judge + ) { + pending_official_role_overrides.delete(official._id); + return official; + } + official.is_umpire = pending.is_umpire; + official.is_service_judge = pending.is_service_judge; + return official; + } + crouting.register(/^t\/$/, ui_list, change.default_handler); + + function list_show(tournaments) { + const main = uiu.qs('.main'); + uiu.empty(main); + uiu.el(main, 'h1', {}, 'Tournaments'); + tournaments.forEach(function (t) { + const link = uiu.el(main, 'div', 'vlink', t.name || t.key); + link.addEventListener('click', function () { + switch_tournament(t.key, ui_show); + }); + }); + + const create_btn = uiu.el(main, 'button', { + role: 'button', + }, 'Create tournament ...'); + create_btn.addEventListener('click', ui_create); + } + + function update_score(c) { + const cval = c.val; + const match_id = cval.match_id; + + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update score, ID: ' + JSON.stringify(match_id)); + return; + } + + const old_section = cmatch.calc_section(m); + m.network_score = cval.network_score; + m.presses = cval.presses; + m.team1_won = cval.team1_won; + m.shuttle_count = cval.shuttle_count; + if (cval.court_id !== undefined) { + m.setup.court_id = cval.court_id; + } + if (cval.now_on_court !== undefined) { + m.setup.now_on_court = cval.now_on_court; + } + const new_section = cmatch.calc_section(m); + + if (old_section === new_section) { + cmatch.update_match_score(m); + } else { + if (new_section == 'finished' || new_section == 'unassigned') { + m.setup.now_on_court = false; + } + else { + m.setup.now_on_court = true; + } + cmatch.update_match(m, old_section, new_section); + } + } + + function update_player_status(c) { + const cval = c.val; + const match_id = cval.match__id; + + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update player status, ID: ' + JSON.stringify(match_id)); + return; + } + m.btp_winner = cval.btp_winner; + m.setup = cval.setup; + + if(current_view == 'show'){ + cmatch.update_players(m); + } + + } + + function remove_match(c) { + const cval = c.val; + const match_id = cval.match__id; + + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update, ID: ' + JSON.stringify(match_id)); + return; + } + const section = cmatch.calc_section(m); + cmatch.remove_match_from_gui(m, section); + + } + + function add_match(c){ + const cval = c.val; + const m = cval.match; + const new_section = cmatch.calc_section(m); + cmatch.add_match(m, new_section); + } + + function update_match(c) { + const cval = c.val; + const match_id = cval.match__id; + + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update, ID: ' + JSON.stringify(match_id)); + return; + } + const old_section = cmatch.calc_section(m); + if (cval.match) { + if('network_score' in cval.match){ + m.network_score = cval.match.network_score; + } + m.presses = cval.match.presses; + m.team1_won = cval.match.team1_won; + m.shuttle_count = cval.match.shuttle_count; + m.setup = cval.match.setup; + m.btp_winner = cval.match.btp_winner; + } + const new_section = cmatch.calc_section(m); + cmatch.update_match(m, old_section, new_section); + update_location_preparation_need_labels(); + + return old_section; + } + + function rerender_public_match_views(old_section, new_section) { + const affects_courts = ( + old_section.startsWith('court_') || + new_section.startsWith('court_') + ); + const affects_upcoming = ( + old_section === 'unassigned' || + new_section === 'unassigned' + ); + + if ((current_view === 'upcoming' || current_view === 'current_matches') && affects_courts) { + uiu.qsEach('.courts_container', (courts_container) => { + cmatch.render_courts(courts_container, 'public'); + }); + } + + if ((current_view === 'upcoming' || current_view === 'next_matches') && affects_upcoming) { + uiu.qsEach('.upcoming_container', (upcoming_container) => { + cmatch.render_upcoming_matches(upcoming_container); + }); + } + } + + function update_upcoming_match(c) { + const cval = c.val; + const match_id = cval.match__id; + + // Find the match + const m = utils.find(curt.matches, m => m._id === match_id); + if (!m) { + cerror.silent('Cannot find match to update, ID: ' + JSON.stringify(match_id)); + return; + } + const old_section = cmatch.calc_section(m); + if(cval.match.network_score) { + m.network_score = cval.match.network_score; + } + m.presses = cval.match.presses; + m.team1_won = cval.match.team1_won; + m.shuttle_count = cval.match.shuttle_count; + m.setup = cval.match.setup; + m.btp_winner = cval.match.btp_winner; + const new_section = cmatch.calc_section(m); + cmatch.update_match(m, old_section, new_section); + update_location_preparation_need_labels(); + rerender_public_match_views(old_section, new_section); + + if (old_section != new_section || new_section == 'unassigned') { + uiu.qsEach('.upcoming_container', (upcoming_container) => { + cmatch.render_upcoming_matches(upcoming_container); + }); + } + } + + function tabletoperator_add(c) { + curt.tabletoperators.push(c.val.tabletoperator); + _show_render_tabletoperators(); + } + + function tabletoperator_moved_up(c) { + const changed_t = utils.find(curt.tabletoperators, m => m._id === c.val.tabletoperator._id); + if (changed_t) { + changed_t.start_ts = c.val.tabletoperator.start_ts; + } + _show_render_tabletoperators(); + } + + function tabletoperator_moved_down(c) { + const changed_t = utils.find(curt.tabletoperators, m => m._id === c.val.tabletoperator._id); + if (changed_t) { + changed_t.start_ts = c.val.tabletoperator.start_ts; + } + _show_render_tabletoperators(); + } + + function tabletoperator_removed(c) { + const changed_t = utils.find(curt.tabletoperators, m => m._id === c.val.tabletoperator._id); + if (changed_t) { + changed_t.court = c.val.tabletoperator.court; + } + _show_render_tabletoperators(); + } + + function add_normalization(c) { + curt.normalizations.push(c.val.normalization); + update_normalization_values(c) + } + + function remove_normalization(c) { + const changed_t = utils.find(curt.normalizations, m => m._id === c.val.normalization_id); + if (changed_t) { + curt.normalizations.splice(curt.normalizations.indexOf(changed_t), 1); + } + update_normalization_values(c) + } + function update_normalization_values(c) { + uiu.qsEach('.normalizations_values_div', (div_el) => { + div_el.innerHTML = ""; + render_normalisation_values(div_el); + }); + } + + function add_advertisement(c) { + curt.advertisements.push(c.val.advertisement); + update_advertisements(c) + } + + function remove_advertisement(c) { + const changed_t = utils.find(curt.advertisements, m => m._id === c.val.advertisement_id); + if (changed_t) { + curt.advertisements.splice(curt.advertisements.indexOf(changed_t), 1); + } + update_advertisements(c) + } + + function update_advertisements(c) { + uiu.qsEach('.advertisements_div', (div_el) => { + div_el.innerHTML = ""; + render_advertisements(div_el); + }); + } + + function update_current_match(c) { + update_match(c); + } + + function update_upcoming_current_match(c) { + update_upcoming_match(c); + } + + function _update_all_ui_elements() { + _show_render_matches(); + _show_render_tabletoperators(); + + } + + function _update_all_ui_elements_edit() { + update_general_displaysettings(uiu.qs('.general_displaysettings')); + } + + function refresh_current_view() { + switch (current_view) { + case 'edit': + ui_edit(); + break; + case 'show': + ui_show(); + break; + case 'upcoming': + ui_upcoming(); + break; + case 'current_matches': + ui_current_matches(); + break; + case 'next_matches': + ui_next_matches(); + break; + case 'self_check_in': + ui_self_check_in(); + break; + default: + break; + } + } + + function _set_disabled_by_name(field_name, disabled) { + uiu.qsEach('[name="' + field_name + '"]', function(el) { + el.disabled = !!disabled; + }); + } + + function update_edit_dependencies() { + if (current_view !== 'edit') { + return; + } + + const warmup_select = document.querySelector('[name="warmup"]'); + if (warmup_select) { + const custom_warmup = ['choise', 'call-down']; + const is_custom = custom_warmup.includes(warmup_select.value); + _set_disabled_by_name('warmup_ready', !is_custom); + _set_disabled_by_name('warmup_start', !is_custom); + } + + const btp_enabled = !!curt.btp_enabled; + _set_disabled_by_name('btp_autofetch_enabled', !btp_enabled); + _set_disabled_by_name('btp_readonly', !btp_enabled); + _set_disabled_by_name('btp_ip', !btp_enabled); + _set_disabled_by_name('btp_password', !btp_enabled); + _set_disabled_by_name('btp_timezone', !btp_enabled); + _set_disabled_by_name('btp_autofetch_timeout_intervall', !btp_enabled || !curt.btp_autofetch_enabled); + + const ticker_enabled = !!curt.ticker_enabled; + _set_disabled_by_name('ticker_url', !ticker_enabled); + _set_disabled_by_name('ticker_password', !ticker_enabled); + + const tabletoperator_enabled = !!curt.tabletoperator_enabled; + [ + 'tabletoperator_with_umpire_enabled', + 'tabletoperator_winner_of_quaterfinals_enabled', + 'tabletoperator_use_manual_counting_boards_enabled', + 'tabletoperator_split_doubles', + 'tabletoperator_with_state_enabled', + 'tabletoperator_with_state_from_match_enabled', + 'tabletoperator_set_break_after_tabletservice', + 'tabletoperator_break_seconds', + ].forEach(field_name => _set_disabled_by_name(field_name, !tabletoperator_enabled)); + uiu.qsEach('[name="tabletoperator_enabled"]', function(el) { + const box = el.closest('.automation_group_box'); + if (box) { + box.classList.toggle('automation_group_box_content_disabled', !tabletoperator_enabled); + } + }); + + const preparation_automation_enabled = !!curt.call_preparation_matches_automatically_enabled; + [ + 'preparation_successor_rally_count', + 'preparation_call_time_limit_before_scheduled_enabled', + 'preparation_call_time_limit_before_scheduled_minutes', + 'preparation_call_block_ahead_limit_enabled', + 'preparation_call_block_ahead_limit', + 'preparation_call_time_ahead_of_frontier_enabled', + 'preparation_call_time_ahead_of_frontier_minutes', + 'preparation_call_matches_ahead_of_frontier_enabled', + 'preparation_call_matches_ahead_of_frontier_limit', + 'preparation_call_technical_officials_available_enabled', + ].forEach(field_name => _set_disabled_by_name(field_name, !preparation_automation_enabled)); + const call_on_court_automation_enabled = !!curt.call_next_possible_scheduled_match_in_preparation; + [ + 'call_on_court_participant_readiness_mode', + 'call_on_court_technical_officials_mode', + 'call_on_court_require_official_space_enabled', + 'call_on_court_only_preparation_enabled', + 'call_on_court_only_preparation_minutes', + 'call_on_court_time_limit_before_scheduled_enabled', + 'call_on_court_time_limit_before_scheduled_minutes', + 'call_on_court_block_ahead_limit_enabled', + 'call_on_court_block_ahead_limit', + 'call_on_court_time_ahead_of_frontier_enabled', + 'call_on_court_time_ahead_of_frontier_minutes', + 'call_on_court_matches_ahead_of_frontier_enabled', + 'call_on_court_matches_ahead_of_frontier_limit', + ].forEach(field_name => _set_disabled_by_name(field_name, !call_on_court_automation_enabled)); + + const technical_official_rotation_enabled = (curt.official_rotation_mode || 'umpire_and_service_judge') !== 'disabled'; + const technical_official_auto_assignment_mode = curt.technical_official_auto_assignment_mode || 'manual_only'; + const preparation_officials_rule_mode_supported = + technical_official_auto_assignment_mode === 'when_available' || + technical_official_auto_assignment_mode === 'on_preparation_call'; + const preparation_officials_rule_enabled = + preparation_automation_enabled && + technical_official_rotation_enabled && + preparation_officials_rule_mode_supported; + if (!preparation_officials_rule_enabled && curt.preparation_call_technical_officials_available_enabled) { + curt.preparation_call_technical_officials_available_enabled = false; + const checkbox = document.querySelector('[name="preparation_call_technical_officials_available_enabled"]'); + if (checkbox) { + checkbox.checked = false; + } + send_single_prop('preparation_call_technical_officials_available_enabled', false, function(err) { + if (err) { + cerror.net(err); + } + }); + } + _set_disabled_by_name('preparation_call_technical_officials_available_enabled', !preparation_officials_rule_enabled); + uiu.qsEach('[name="preparation_call_technical_officials_available_enabled"]', function(el) { + const label = el.closest('label'); + if (label) { + label.classList.toggle('automation_suboption_checkbox_disabled', !preparation_officials_rule_enabled); + } + const hint = label ? label.nextElementSibling : null; + if (hint && hint.classList.contains('automation_suboption_hint')) { + let hint_key = null; + if (preparation_automation_enabled) { + if (!technical_official_rotation_enabled) { + hint_key = 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_rotation_disabled'; + } else if (!preparation_officials_rule_mode_supported) { + hint_key = 'tournament:edit:preparation_call_technical_officials_available_enabled:hint_auto_assignment_mode'; + } + } + hint.style.display = hint_key ? 'block' : 'none'; + if (hint_key) { + uiu.text(hint, ci18n(hint_key)); + } + } + }); + const call_on_court_officials_rule_mode_supported = + technical_official_auto_assignment_mode === 'when_available' || + technical_official_auto_assignment_mode === 'on_preparation_call' || + technical_official_auto_assignment_mode === 'on_match_call_if_possible'; + const call_on_court_officials_select_enabled = + call_on_court_automation_enabled && + technical_official_rotation_enabled; + const call_on_court_officials_available_option_enabled = + call_on_court_officials_select_enabled && + call_on_court_officials_rule_mode_supported; + const call_on_court_officials_mode = curt.call_on_court_technical_officials_mode || 'disabled'; + if ((!call_on_court_officials_select_enabled && call_on_court_officials_mode !== 'disabled') || + (!call_on_court_officials_available_option_enabled && call_on_court_officials_mode === 'available')) { + curt.call_on_court_technical_officials_mode = 'disabled'; + const select = document.querySelector('[name="call_on_court_technical_officials_mode"]'); + if (select) { + select.value = 'disabled'; + } + send_single_prop('call_on_court_technical_officials_mode', 'disabled', function(err) { + if (err) { + cerror.net(err); + } + }); + } + _set_disabled_by_name('call_on_court_technical_officials_mode', !call_on_court_officials_select_enabled); + _set_disabled_by_name('call_on_court_require_official_space_enabled', !call_on_court_officials_select_enabled); + uiu.qsEach('[name="call_on_court_technical_officials_mode"]', function(el) { + const box = el.closest('.automation_rule_box'); + if (box) { + box.classList.toggle('automation_rule_box_disabled', !call_on_court_officials_select_enabled); + } + const available_option = el.querySelector('option[value="available"]'); + if (available_option) { + available_option.disabled = !call_on_court_officials_available_option_enabled; + } + const hint = box ? box.nextElementSibling : null; + if (hint && hint.classList.contains('automation_suboption_hint')) { + let hint_key = null; + if (call_on_court_automation_enabled) { + if (!technical_official_rotation_enabled) { + hint_key = 'tournament:edit:call_on_court_technical_officials_mode:hint_rotation_disabled'; + } else if (!call_on_court_officials_rule_mode_supported) { + hint_key = 'tournament:edit:call_on_court_technical_officials_mode:hint_auto_assignment_mode'; + } + } + hint.style.display = hint_key ? 'block' : 'none'; + if (hint_key) { + uiu.text(hint, ci18n(hint_key)); + } + } + }); + uiu.qsEach('[name="call_on_court_require_official_space_enabled"]', function(el) { + const label = el.closest('label'); + if (label) { + label.classList.toggle('automation_suboption_checkbox_disabled', !call_on_court_officials_select_enabled); + } + }); + uiu.qsEach('[name="call_preparation_matches_automatically_enabled"]', function(el) { + const box = el.closest('.automation_group_box'); + if (box) { + box.classList.toggle('automation_group_box_content_disabled', !preparation_automation_enabled); + } + }); + uiu.qsEach('[name="call_next_possible_scheduled_match_in_preparation"]', function(el) { + const box = el.closest('.automation_group_box'); + if (box) { + box.classList.toggle('automation_group_box_content_disabled', !call_on_court_automation_enabled); + } + }); + + [ + ['preparation_call_time_limit_before_scheduled_enabled', 'preparation_call_time_limit_before_scheduled_minutes'], + ['preparation_call_block_ahead_limit_enabled', 'preparation_call_block_ahead_limit'], + ['preparation_call_time_ahead_of_frontier_enabled', 'preparation_call_time_ahead_of_frontier_minutes'], + ['preparation_call_matches_ahead_of_frontier_enabled', 'preparation_call_matches_ahead_of_frontier_limit'], + ].forEach(([enabled_field, value_field]) => { + const enabled = !!curt[enabled_field]; + _set_disabled_by_name(value_field, !preparation_automation_enabled || !enabled); + uiu.qsEach('[name="' + enabled_field + '"]', function(el) { + const box = el.closest('.automation_rule_box'); + if (box) { + box.classList.toggle('automation_rule_box_disabled', !preparation_automation_enabled); + box.classList.toggle('automation_rule_box_value_disabled', preparation_automation_enabled && !enabled); + } + }); + }); + [ + 'call_on_court_participant_readiness_mode', + ['call_on_court_only_preparation_enabled', 'call_on_court_only_preparation_minutes'], + ['call_on_court_time_limit_before_scheduled_enabled', 'call_on_court_time_limit_before_scheduled_minutes'], + ['call_on_court_block_ahead_limit_enabled', 'call_on_court_block_ahead_limit'], + ['call_on_court_time_ahead_of_frontier_enabled', 'call_on_court_time_ahead_of_frontier_minutes'], + ['call_on_court_matches_ahead_of_frontier_enabled', 'call_on_court_matches_ahead_of_frontier_limit'], + ].forEach((entry) => { + if (typeof entry === 'string') { + uiu.qsEach('[name="' + entry + '"]', function(el) { + const box = el.closest('.automation_rule_box'); + if (box) { + box.classList.toggle('automation_rule_box_disabled', !call_on_court_automation_enabled); + } + }); + return; + } + const [enabled_field, value_field] = entry; + const enabled = !!curt[enabled_field]; + _set_disabled_by_name(value_field, !call_on_court_automation_enabled || !enabled); + uiu.qsEach('[name="' + enabled_field + '"]', function(el) { + const box = el.closest('.automation_rule_box'); + if (box) { + box.classList.toggle('automation_rule_box_disabled', !call_on_court_automation_enabled); + box.classList.toggle('automation_rule_box_value_disabled', call_on_court_automation_enabled && !enabled); + } + }); + }); + + apply_court_official_checkbox_dependencies(); + } + + function set_live_settings_status(status_key) { + if (!live_settings_status_el) { + return; + } + live_settings_status_el.className = 'live_settings_status live_settings_status_' + status_key; + uiu.text(live_settings_status_el, ci18n('tournament:edit:live_status:' + status_key)); + } + + function set_live_settings_status_message(message, status_key) { + if (!live_settings_status_el) { + return false; + } + live_settings_status_el.className = 'live_settings_status live_settings_status_' + (status_key || 'saved'); + uiu.text(live_settings_status_el, message); + return true; + } + + function begin_live_settings_request() { + live_settings_pending_requests += 1; + set_live_settings_status('saving'); + } + + function end_live_settings_request(err) { + live_settings_pending_requests = Math.max(0, live_settings_pending_requests - 1); + if (err) { + set_live_settings_status('error'); + return; + } + if (live_settings_pending_requests === 0) { + set_live_settings_status('saved'); + } else { + set_live_settings_status('saving'); + } + } + + function send_single_prop(field, value, callback) { + begin_live_settings_request(); + send({ + type: 'tournament_edit_prop', + key: curt.key, + field, + value, + }, (err) => { + end_live_settings_request(err); + if (callback) { + callback(err); + } + }); + } + + function send_with_live_status(msg, callback) { + begin_live_settings_request(); + send(msg, function(err, response) { + end_live_settings_request(err); + if (callback) { + return callback(err, response); + } + }); + } + + function bind_live_prop(el, field, options) { + options = options || {}; + const event_name = options.event_name || 'change'; + const get_value = options.get_value || function(input_el) { + if (input_el.type === 'checkbox') { + return input_el.checked; + } + return input_el.value; + }; + const on_before_send = options.on_before_send || function() {}; + const on_success = options.on_success || function() {}; + const on_error = options.on_error || function(input_el, old_value) { + if (input_el.type === 'checkbox') { + input_el.checked = !!old_value; + } else { + input_el.value = old_value ?? ''; + } + }; + + el.addEventListener(event_name, function() { + const old_value = curt[field]; + on_before_send(el); + const value = get_value(el); + send_single_prop(field, value, function(err) { + if (err) { + on_error(el, old_value); + return cerror.net(err); + } + curt[field] = value; + update_edit_dependencies(); + on_success(el, value); + }); + }); + } + + function _update_all_ui_elements_upcoming() { + cmatch.render_courts(uiu.qs('.courts_container'), 'public'); + cmatch.render_upcoming_matches(uiu.qs('.upcoming_container')); + } + + function _update_all_ui_elements_current_matches() { + cmatch.render_courts(uiu.qs('.courts_container'), 'public'); + } + + function _update_all_ui_elements_next_matches() { + cmatch.render_upcoming_matches(uiu.qs('.upcoming_container')); + } + + function _show_render_matches() { + cmatch.render_courts(uiu.qs('.courts_container')); + cmatch.render_unassigned(uiu.qs('.unassigned_container')); + cmatch.render_finished(uiu.qs('.finished_container')); + update_location_preparation_need_labels(); + } + function _show_render_tabletoperators() { + if(curt.tabletoperator_enabled) { + ctabletoperator.render_unassigned(uiu.qs('.unassigned_tableoperators_container')); + } + } + + function update_show_tabletoperators() { + if (current_view !== 'show') { + return; + } + const meta_div = document.querySelector('.metadata_container'); + if (!meta_div) { + return; + } + let container = meta_div.querySelector('.unassigned_tableoperators_container'); + if (curt.tabletoperator_enabled) { + if (!container) { + container = document.createElement('div'); + container.className = 'unassigned_tableoperators_container'; + meta_div.insertBefore(container, meta_div.firstChild); + } else { + container.innerHTML = ''; + } + _show_render_tabletoperators(); + } else if (container) { + container.remove(); + } + } + + function update_btp_settings_ui() { + switch (current_view) { + case 'show': + _show_render_matches(); + _show_render_umpires(); + break; + case 'upcoming': + _update_all_ui_elements_upcoming(); + break; + case 'current_matches': + _update_all_ui_elements_current_matches(); + break; + case 'next_matches': + _update_all_ui_elements_next_matches(); + break; + case 'edit': + update_edit_dependencies(); + break; + default: + break; + } + } + + function _show_render_umpires() { + cumpires.ui_status(uiu.qs('.umpire_container')); + } + + + + function ui_btp_fetch() { + send({ + type: 'btp_fetch', + tournament_key: curt.key, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } + + function ui_ticker_push() { + send({ + type: 'ticker_reset', + tournament_key: curt.key, + }, err => { + if (err) { + return cerror.net(err); + } + }); + } + + // function render_announcement_formular(target) { + // const announcements = uiu.el(target, 'div', 'announcements_container'); + // const heading = uiu.el(announcements, 'h3', {}, 'Freie Ansage'); + // const form = uiu.el(announcements, 'form'); + // uiu.el(form, 'textarea', { + // type: 'textarea', + // id: 'custom_announcement', + // name: 'custom_announcement', + // cols: '50', + // rows: '4', + // maxlength: '175' + // }); + // const btp_fetch_btn = uiu.el(form, 'button', { + // 'class': 'match_save_button', + // role: 'submit', + // }, 'Ansage abspielen'); + // form_utils.onsubmit(form, function (d) { + // //announce([d.custom_announcement]); + // send({ + // type: 'free_announce', + // tournament_key: curt.key, + // text: d.custom_announcement, + // }, function (err) { + // if (err) { + // return cerror.net(err); + // } + // }); + // }); + // } + + function render_announcement_formular(target) { + const announcements = uiu.el(target, 'div', 'announcements_container'); + uiu.el(announcements, 'h3', {}, 'Freie Ansage'); + + const form = uiu.el(announcements, 'form'); + + const textarea = uiu.el(form, 'textarea', { + type: 'textarea', + id: 'custom_announcement', + name: 'custom_announcement', + cols: '50', + rows: '4', + maxlength: '175' + }); + + const btn_container = uiu.el(form, 'div', 'announcements_btn_container'); + + // Button: Lokal Abspielen + const local_btn = uiu.el(btn_container, 'button', { + type: 'button', + class: 'announce_button', + id: 'local_announce_btn' + }, 'Lokal Abspielen'); + + // Button: Remote Abspielen + const remote_btn = uiu.el(btn_container, 'button', { + type: 'submit', + class: 'announce_button', + id: 'remote_announce_btn' + }, 'Remote Abspielen'); + + const emergency_btn = uiu.el(btn_container, 'button', { + type: 'submit', + class: !curt.enable_emergency ? 'announce_emergency_button' : 'stop_emergency_button', + id: 'announce_emergency_btn' + }, !curt.enable_emergency ? 'Evakuierung Abspielen' : 'Evakuierung Stoppen'); + + // Lokales Abspielen (z. B. mit deiner announce-Funktion) + local_btn.addEventListener('click', function () { + const text = textarea.value.trim(); + if (!text) return; + + // Lokale Ansage abspielen + announce([text], true); // ← Diese Funktion muss bei dir lokal definiert sein + }); + + emergency_btn.addEventListener("click", () => { + const bestaetigt = confirm(!curt.enable_emergency ? "Soll wirklich evakuiert werden?" : "Soll die Evakuierung wirklich abgebrochen werden?"); + + if (bestaetigt) { + send({ + type: 'emergency_announce', + tournament_key: curt.key, + enable: !curt.enable_emergency + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + } + }); + + // Remote Abspielen + form_utils.onsubmit(form, function (d) { + const text = d.custom_announcement?.trim(); + if (!text) return; + + send({ + type: 'free_announce', + tournament_key: curt.key, + text: text, + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function update_emergency_btn() { + const btn = document.getElementById('announce_emergency_btn'); + if (!btn) return; + + if (curt.enable_emergency) { + btn.classList.remove('announce_emergency_button'); + btn.classList.add('stop_emergency_button'); + btn.textContent = 'Evakuierung Stoppen'; + } else { + btn.classList.remove('stop_emergency_button'); + btn.classList.add('announce_emergency_button'); + btn.textContent = 'Evakuierung Abspielen'; + } + } + + // function render_enable_announcement(target) { + // const announcements = uiu.el(target, 'div', 'enable_announcements_container'); + // const heading = uiu.el(announcements, 'h3', {}, 'Ansagen auf diesem Gerät'); + // const form = uiu.el(announcements, 'form'); + // const enable_announcements = uiu.el(form, 'input', { + // type: 'checkbox', + // id: 'enable_announcements', + // name: 'enable_announcements' + // }); + + // enable_announcements.checked = (window.localStorage.getItem('enable_announcements') === 'true'); + // uiu.el(form, 'label', { for: 'enable_announcements' }, 'aktiv'); + // enable_announcements.addEventListener('change', change_announcements); + // } + + // function change_announcements(e) { + // let enable_announcements = document.getElementById('enable_announcements'); + // window.localStorage.setItem('enable_announcements', enable_announcements.checked); + // } + + function render_enable_announcements(target, locations) { + const container = uiu.el(target, 'div', 'enable_announcements_container'); + uiu.el(container, 'h3', {}, 'Ansagen auf diesem Gerät'); + + locations.forEach(loc => { + { + const form = uiu.el(container, 'form'); + + const checkboxId = `enable_announcement_calls_${loc._id}`; + const checkbox = uiu.el(form, 'input', { + type: 'checkbox', + id: checkboxId, + name: checkboxId + }); + + // Initialer Zustand aus localStorage + checkbox.checked = (window.localStorage.getItem(checkboxId) === 'true'); + + // Label anzeigen mit dem Location-Namen + uiu.el(form, 'label', { for: checkboxId }, (loc.name || 'Unbenannte Location') + " (Spielaufruf)"); + + // Event Listener zum Speichern in localStorage + checkbox.addEventListener('change', function () { + window.localStorage.setItem(checkboxId, checkbox.checked); + }); + } + { + const form = uiu.el(container, 'form'); + + const checkboxId = `enable_announcement_preparations_${loc._id}`; + const checkbox = uiu.el(form, 'input', { + type: 'checkbox', + id: checkboxId, + name: checkboxId + }); + + // Initialer Zustand aus localStorage + checkbox.checked = (window.localStorage.getItem(checkboxId) === 'true'); + + // Label anzeigen mit dem Location-Namen + uiu.el(form, 'label', { for: checkboxId }, (loc.name || 'Unbenannte Location') + " (in Vorbereitung)"); + + // Event Listener zum Speichern in localStorage + checkbox.addEventListener('change', function () { + window.localStorage.setItem(checkboxId, checkbox.checked); + }); + } + }); + + { + const form = uiu.el(container, 'form'); + + const checkboxId = 'enable_free_announcements'; + const checkbox = uiu.el(form, 'input', { + type: 'checkbox', + id: checkboxId, + name: checkboxId + }); + + // Initialer Zustand aus localStorage + checkbox.checked = (window.localStorage.getItem(checkboxId) === 'true'); + + // Label anzeigen mit dem Location-Namen + uiu.el(form, 'label', { for: checkboxId }, 'Freie Remote Ansagen'); + + // Event Listener zum Speichern in localStorage + checkbox.addEventListener('change', function () { + window.localStorage.setItem(checkboxId, checkbox.checked); + }); + } + + { + const form = uiu.el(container, 'form', 'announcement_speech_check_form'); + const statusWrap = uiu.el(form, 'div', 'announcement_speech_check_status'); + const statusLabel = uiu.el(statusWrap, 'span', 'announcement_speech_check_label', ci18n('announcements:speechcheck:label')); + const statusValue = uiu.el(statusWrap, 'span', 'announcement_speech_check_value'); + const button = uiu.el(form, 'button', { + type: 'button', + }, ci18n('announcements:speechcheck:button')); + + const updateSpeechCheckStatus = function(state) { + const current = state || (typeof getAnnouncementSpeechCheckState === 'function' + ? getAnnouncementSpeechCheckState() + : { status: 'unsupported', detail: ci18n('announcements:speechcheck:unsupported') }); + statusValue.className = 'announcement_speech_check_value announcement_speech_check_value_' + (current.status || 'untested'); + statusValue.textContent = current.detail || ci18n('announcements:speechcheck:untested'); + }; + + updateSpeechCheckStatus(); + + button.addEventListener('click', function() { + if (typeof runAnnouncementSpeechCheck !== 'function') { + updateSpeechCheckStatus({ status: 'unsupported', detail: ci18n('announcements:speechcheck:unsupported') }); + return; + } + button.disabled = true; + statusValue.className = 'announcement_speech_check_value announcement_speech_check_value_running'; + statusValue.textContent = ci18n('announcements:speechcheck:running'); + Promise.resolve(runAnnouncementSpeechCheck()).then((state) => { + updateSpeechCheckStatus(state); + button.disabled = false; + }).catch(() => { + updateSpeechCheckStatus({ status: 'error', detail: ci18n('announcements:speechcheck:error') }); + button.disabled = false; + }); + }); + } + } + + function render_enable_location_courts(target, locations) { + const container = uiu.el(target, 'div', 'enable_announcements_container'); + uiu.el(container, 'h3', {}, 'Zeige Felder'); + + locations.forEach(loc => { + const form = uiu.el(container, 'form'); + + const checkboxId = `show_location_courts_${loc._id}`; + const checkbox = uiu.el(form, 'input', { + type: 'checkbox', + id: checkboxId, + name: checkboxId + }); + + // Initialer Zustand aus localStorage oder Default auf true + const storedValue = window.localStorage.getItem(checkboxId); + checkbox.checked = (storedValue === null) ? true : (storedValue === 'true'); + + // Label anzeigen mit dem Location-Namen + uiu.el(form, 'label', { + for: checkboxId, + 'data-location-need-label': loc._id, + }, format_location_courts_label(loc)); + + // Event Listener zum Speichern in localStorage und Aufruf mit Parametern + checkbox.addEventListener('change', function () { + window.localStorage.setItem(checkboxId, checkbox.checked); + cmatch.update_tables(loc._id, checkbox.checked); + }); + + // Gleich initial einmal aufrufen, damit der Sichtbarkeitszustand korrekt gesetzt ist + cmatch.update_tables(loc._id, checkbox.checked); + }); + } + + function calculate_location_preparation_need_statuses() { + const courts = Array.isArray(curt.courts) ? curt.courts : []; + const matches = Array.isArray(curt.matches) ? curt.matches : []; + const status_by_location_id = new Map(); + const occupied_court_ids = new Set( + matches + .filter((match) => { + const setup = match && match.setup; + return !!setup && setup.now_on_court === true && setup.court_id; + }) + .map((match) => match.setup.court_id) + ); + + (curt.locations || []).forEach((loc) => { + const location_courts = courts.filter((court) => court && court.location_id === loc._id); + const active_location_courts = location_courts.filter((court) => court && court.is_active === true); + const active_location_court_ids = new Set(active_location_courts.map((court) => court._id)); + const successor_need_count = matches.filter((match) => { + const setup = match && match.setup; + return !!setup + && setup.now_on_court === true + && setup.needs_preparation_successor === true + && active_location_court_ids.has(setup.court_id); + }).length; + const free_court_count = active_location_courts.filter((court) => !occupied_court_ids.has(court._id)).length; + const active_court_count = active_location_courts.length; + const required_preparation_count = Math.min(active_court_count, successor_need_count + free_court_count); + const current_preparation_count = matches.filter((match) => { + const setup = match && match.setup; + return !!setup && setup.state === 'preparation' && setup.location_id === loc._id; + }).length; + status_by_location_id.set(loc._id, { + location_id: loc._id, + active_court_count, + successor_need_count, + free_court_count, + required_preparation_count, + current_preparation_count, + missing_preparation_count: Math.max(0, required_preparation_count - current_preparation_count), + }); + }); + + return status_by_location_id; + } + + function request_location_preparation_selections() { + if (!curt || !curt.key) { + return; + } + if (preparation_selection_request_inflight) { + preparation_selection_request_pending = true; + return; + } + preparation_selection_request_inflight = true; + send({ + type: 'preparation_selection_get', + tournament_key: curt.key, + }, function(err, response) { + preparation_selection_request_inflight = false; + if (err) { + if (preparation_selection_request_pending) { + preparation_selection_request_pending = false; + request_location_preparation_selections(); + } + return; + } + const selection_by_location_id = {}; + (response.selections || []).forEach((selection) => { + if (selection && selection.location_id != null) { + selection_by_location_id[String(selection.location_id)] = selection; + } + }); + curt.location_preparation_selection_by_location_id = selection_by_location_id; + const labels = document.querySelectorAll('[data-location-need-label]'); + if (labels.length) { + update_location_preparation_need_labels(false); + } + if (preparation_selection_request_pending) { + preparation_selection_request_pending = false; + request_location_preparation_selections(); + } + }); + } + + function format_location_courts_label(location) { + const location_name = (location.name + " [" + location.short_name + "]") || 'Unbenannte Location'; + return location_name; + } + + function update_location_preparation_need_labels(fetch_selections = true) { + const labels = document.querySelectorAll('[data-location-need-label]'); + if (!labels.length) { + return; + } + if (fetch_selections) { + request_location_preparation_selections(); + } + const statuses = calculate_location_preparation_need_statuses(); + labels.forEach((label) => { + const location_id = label.getAttribute('data-location-need-label'); + const location = utils.find(curt.locations || [], (loc) => String(loc._id) === String(location_id)); + if (!location) { + return; + } + const location_name = (location.name + " [" + location.short_name + "]") || 'Unbenannte Location'; + label.textContent = location_name; + }); + } + + function render_automation_controls(target) { + const container = uiu.el(target, 'div', 'automation_controls_panel'); + const overall_active = curt.automation_enabled !== false; + const orbit = uiu.el(container, 'div', 'automation_orbit' + (overall_active ? ' is-global-active' : ' is-paused-global')); + + const add_outer_toggle = function(position_class, icon_class, active, on_toggle, title, extra_class) { + const button = uiu.el(orbit, 'button', { + type: 'button', + 'class': [ + 'automation_outer_toggle', + position_class, + active ? 'is-active' : 'is-paused', + extra_class || '', + ].filter(Boolean).join(' '), + 'title': title, + 'aria-label': title, + }); + const icon = uiu.el(button, 'span', 'automation_outer_toggle_icon ' + icon_class); + if (icon_class === 'automation_icon_rotation_dual') { + uiu.el(icon, 'span', 'automation_icon_rotation_dual_swap', '⇄'); + } + if (typeof on_toggle === 'function') { + button.addEventListener('click', on_toggle); + } else { + button.disabled = true; + } + return button; + }; + + const preparation_active = !!curt.call_preparation_matches_automatically_enabled; + const on_court_active = !!curt.call_next_possible_scheduled_match_in_preparation; + const rotation_mode = curt.official_rotation_mode || 'umpire_and_service_judge'; + const next_rotation_mode = function(mode) { + if (mode === 'disabled') return 'umpire_only'; + if (mode === 'umpire_only') return 'umpire_and_service_judge'; + return 'disabled'; + }; + const orbit_segment_color = function(active, reserved) { + if (reserved) { + return '#8e948d'; + } + return active ? '#00c000' : '#101010'; + }; + orbit.style.setProperty('--automation-segment-top', orbit_segment_color(rotation_mode !== 'disabled', false)); + orbit.style.setProperty('--automation-segment-right', orbit_segment_color(preparation_active, false)); + const tabletoperator_enabled = !!curt.tabletoperator_enabled; + orbit.style.setProperty('--automation-segment-bottom', orbit_segment_color(on_court_active, false)); + orbit.style.setProperty('--automation-segment-left', orbit_segment_color(tabletoperator_enabled, false)); + + add_outer_toggle( + 'automation_outer_top', + rotation_mode === 'disabled' + ? 'automation_icon_rotation_disabled' + : (rotation_mode === 'umpire_only' + ? 'automation_icon_rotation_umpire' + : 'automation_icon_rotation_dual'), + rotation_mode !== 'disabled', + function() { + send_single_prop('official_rotation_mode', next_rotation_mode(rotation_mode), function(err) { + if (err) { + return cerror.net(err); + } + }); + }, + rotation_mode === 'disabled' + ? 'Rotation deaktiviert' + : (rotation_mode === 'umpire_only' + ? 'Nur Schiedsrichterrotation' + : 'Schieds- und Aufschlagrichterrotation') + ); + + add_outer_toggle( + 'automation_outer_right', + preparation_active + ? 'automation_icon_preparation_enabled' + : 'automation_icon_preparation_disabled', + preparation_active, + function() { + send_single_prop('call_preparation_matches_automatically_enabled', !preparation_active, function(err) { + if (err) { + return cerror.net(err); + } + }); + }, + 'Automatik fuer Spiele in Vorbereitung' + ); + + add_outer_toggle( + 'automation_outer_bottom', + on_court_active + ? 'automation_icon_oncourt_enabled' + : 'automation_icon_oncourt_disabled', + on_court_active, + function() { + send_single_prop('call_next_possible_scheduled_match_in_preparation', !on_court_active, function(err) { + if (err) { + return cerror.net(err); + } + }); + }, + 'Automatik fuer Spiele aufs Feld' + ); + + add_outer_toggle( + 'automation_outer_left', + tabletoperator_enabled + ? 'automation_icon_tablet_enabled' + : 'automation_icon_tablet_disabled', + tabletoperator_enabled, + function() { + send_single_prop('tabletoperator_enabled', !tabletoperator_enabled, function(err) { + if (err) { + return cerror.net(err); + } + }); + }, + 'Tabletbediener einsetzen' + ); + + const center = uiu.el(orbit, 'button', { + type: 'button', + 'class': 'automation_center_toggle ' + (overall_active ? 'is-active' : 'is-paused'), + 'title': overall_active ? 'Gesamte Automatik pausieren' : 'Gesamte Automatik starten', + 'aria-label': overall_active ? 'Gesamte Automatik pausieren' : 'Gesamte Automatik starten', + }); + uiu.el(center, 'span', 'automation_center_toggle_label', 'AUTO'); + const center_icon_slot = uiu.el(center, 'span', 'automation_center_toggle_icon_slot'); + uiu.el(center_icon_slot, 'span', 'automation_center_toggle_icon automation_center_toggle_icon_current ' + (overall_active ? 'is-play' : 'is-pause'), overall_active ? '▶' : '❚❚'); + uiu.el(center_icon_slot, 'span', 'automation_center_toggle_icon automation_center_toggle_icon_preview ' + (overall_active ? 'is-pause' : 'is-play'), overall_active ? '❚❚' : '▶'); + center.addEventListener('click', function() { + const next_value = !overall_active; + send_single_prop('automation_enabled', next_value, function(err) { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function update_show_automation_controls() { + if (current_view !== 'show') { + return; + } + uiu.qsEach('.automation_controls_panel', function(panel) { + const parent = panel.parentNode; + if (!parent) { + return; + } + uiu.remove(panel); + render_automation_controls(parent); + }); + } + + function build_location_view_menu_items() { + const base_path = '/admin/t/' + encodeURIComponent(curt.key); + const bup_lang = ((curt.language && curt.language !== 'auto') ? '&lang=' + encodeURIComponent(curt.language) : ''); + const bup_dm_style = '&dm_style=' + encodeURIComponent(curt.dm_style || 'international'); + const locations = curt.locations || []; + + function section_items(label, path_suffix) { + const items = [{ + label, + href: base_path + path_suffix, + }]; + + if (locations.length > 1) { + locations.forEach((loc) => { + const params = new URLSearchParams({ + location: loc.name, + }); + items.push({ + label: label + ' (' + ci18n('only location') + ' ' + loc.name + ')', + href: base_path + path_suffix + '?' + params.toString(), + }); + }); + } + + items.push({ + class: 'toprow_menu_separator', + }); + + return items; + } + + const view_items = [ + ...section_items(ci18n('Matchoverview'), '/upcoming'), + ...section_items(ci18n('Current Matches'), '/current_matches'), + ...section_items(ci18n('Next Matches'), '/next_matches'), + ...section_items(ci18n('Self-Check-In'), '/self_check_in'), + ]; + if (view_items.length > 0 && view_items[view_items.length - 1].class === 'toprow_menu_separator') { + view_items.pop(); + } + + const items = [{ + label: ci18n('edit BTS settings'), + href: base_path + '/edit', + }]; + if (curt.btp_enabled) { + items.push({ + label: ci18n('update BTP data'), + func: ui_btp_fetch, + }); + } + if (curt.ticker_enabled) { + items.push({ + label: ci18n('update ticker'), + func: ui_ticker_push, + }); + } + items.push({ + class: 'toprow_menu_separator', + }, { + label: ci18n('Scoreboard'), + href: '/bup/#btsh_e=' + encodeURIComponent(curt.key) + '&display' + bup_dm_style + bup_lang, + }, { + class: 'toprow_menu_separator', + }, { + label: ci18n('Umpire Panel'), + href: '/bup/#btsh_e=' + encodeURIComponent(curt.key) + bup_lang, + }, { + class: 'toprow_menu_separator', + }, + ...view_items); + + return items; + } + + function build_show_toprow_right_items() { + return [{ + label: 'BTS', + class: 'status_label', + }, { + label: '', + class: 'toprow_service_badge status_badge', + }, { + label: 'BTP', + class: 'btp_status_label', + }, { + label: '', + class: 'toprow_service_badge btp_status_badge', + }, { + label: 'Ticker', + class: 'ticker_status_label', + }, { + label: '', + class: 'toprow_service_badge ticker_status_badge', + }, { + label: 'Sprachausgabe', + class: 'speech_output_status_label', + }, { + label: '', + class: 'toprow_service_badge speech_output_status_badge', + }, { + label: '\u2630', + class: 'toprow_menu_button', + items: build_location_view_menu_items(), + }]; + } + + function set_badge_text(badge, text) { + if (!badge) { + return; + } + while (badge.firstChild) { + badge.removeChild(badge.firstChild); + } + badge.textContent = text; + } + + function set_btp_badge_countdown(badge, ms_remaining) { + if (!badge) { + return; + } + while (badge.firstChild) { + badge.removeChild(badge.firstChild); + } + uiu.el(badge, 'span', 'toprow_service_badge_prefix', 'Sync in:'); + uiu.el(badge, 'span', 'toprow_service_badge_timer', format_btp_next_fetch_remaining(ms_remaining)); + } + + function service_badge_text(status_name) { + switch (status_name) { + case 'connected': + return 'aktiv'; + case 'connecting': + return 'connecting'; + case 'error': + return 'error'; + case 'deactivated': + return 'aus'; + case 'waiting': + return 'wartet'; + default: + return status_name || ''; + } + } + + function speech_output_badge_text(status_name) { + switch (status_name) { + case 'active': + case 'ok': + return 'aktiv'; + case 'running': + return 'sync...'; + case 'untested': + return 'offen'; + case 'unsupported': + return 'kein support'; + case 'suspicious': + return 'unsicher'; + case 'timeout': + return 'timeout'; + case 'error': + return 'error'; + default: + return status_name || 'offen'; + } + } + + function speech_output_badge_class(status_name) { + switch (status_name) { + case 'active': + case 'ok': + return 'status_connected'; + case 'running': + return 'status_connected is-fetching'; + case 'untested': + return 'status_waiting'; + case 'unsupported': + return 'status_deactivated'; + case 'suspicious': + case 'timeout': + case 'error': + return 'status_error'; + default: + return 'status_waiting'; + } + } + + function update_service_badge(service_id, c) { + if (!c || !c.val) { + return; + } + const badge_class = service_id + '_badge'; + uiu.qsEach('.' + badge_class, (badge_el) => { + badge_el.className = 'toprow_service_badge ' + badge_class + ' status_' + c.val.status; + badge_el.title = c.val.message || ''; + set_badge_text(badge_el, service_badge_text(c.val.status)); + }); + } + + function update_speech_output_badge(state) { + const current = state || (typeof getAnnouncementSpeechCheckState === 'function' + ? getAnnouncementSpeechCheckState() + : { status: 'untested', detail: '' }); + uiu.qsEach('.speech_output_status_badge', (badge_el) => { + badge_el.className = 'toprow_service_badge speech_output_status_badge ' + speech_output_badge_class(current.status); + badge_el.title = current.detail || ''; + set_badge_text(badge_el, speech_output_badge_text(current.status)); + }); + } + + function ensure_speech_output_badge_listener() { + if (speech_output_badge_listener_registered) { + return; + } + speech_output_badge_listener_registered = true; + window.addEventListener('announcement-speech-check-state-changed', function(event) { + update_speech_output_badge(event && event.detail ? event.detail : null); + }); + window.addEventListener('storage', function(event) { + if (event.key !== ANNOUNCEMENT_SPEECH_CHECK_STATE_STORAGE_KEY) { + return; + } + update_speech_output_badge(); + }); + } + + function render_show_toprow() { + toprow.set([{ + label: ci18n('Tournaments'), + func: ui_list, + }, { + label: curt.name || curt.key, + func: ui_show, + 'class': 'ct_name', + }], build_show_toprow_right_items()); + ensure_btp_next_fetch_countdown(); + ensure_speech_output_badge_listener(); + bts_status_changed({ val: curt.status || { status: 'connected', message: '' } }); + btp_status_changed({ val: curt.btp_status }); + ticker_status_changed({ val: curt.ticker_status || { status: 'deactivated', message: '' } }); + update_speech_output_badge(); + } + + function format_btp_next_fetch_remaining(ms_remaining) { + const total_seconds = Math.max(0, Math.ceil(ms_remaining / 1000)); + const minutes = Math.floor(total_seconds / 60); + const seconds = total_seconds % 60; + return minutes + ':' + String(seconds).padStart(2, '0'); + } + + function update_btp_next_fetch_countdown() { + const countdown = uiu.qs('.btp_status_badge'); + if (!countdown || !curt) { + return; + } + const btp_status = curt.btp_status || {}; + const next_fetch_ts = btp_status.next_fetch_ts; + const status_name = btp_status.status; + countdown.className = 'toprow_service_badge btp_status_badge'; + if (!curt.btp_enabled || !curt.btp_autofetch_enabled) { + set_badge_text(countdown, service_badge_text(status_name || 'deactivated')); + if (status_name) { + countdown.classList.add('status_' + status_name); + } + countdown.title = ''; + return; + } + if (status_name === 'error') { + set_badge_text(countdown, 'error'); + countdown.title = btp_status.message || 'BTP-Fehler'; + countdown.classList.add('status_error'); + return; + } + if (status_name === 'connecting') { + set_badge_text(countdown, 'connecting'); + countdown.title = btp_status.message || 'BTP verbindet'; + countdown.classList.add('status_connecting'); + return; + } + if (btp_status.fetch_in_progress) { + set_badge_text(countdown, 'sync...'); + countdown.title = 'BTP-Aktualisierung laeuft'; + countdown.classList.add('status_connected', 'is-fetching'); + return; + } + if (!next_fetch_ts) { + set_badge_text(countdown, service_badge_text(status_name || 'connected')); + countdown.title = btp_status.message || ''; + countdown.classList.add('status_' + (status_name || 'connected')); + return; + } + const ms_remaining = next_fetch_ts - Date.now(); + set_btp_badge_countdown(countdown, ms_remaining); + countdown.title = 'Naechste BTP-Aktualisierung'; + countdown.classList.add('status_connected', 'is-countdown'); + } + + function ensure_btp_next_fetch_countdown() { + if (btp_next_fetch_countdown_interval) { + return; + } + btp_next_fetch_countdown_interval = setInterval(update_btp_next_fetch_countdown, 1000); + } + function ui_show() { + current_view = 'show' + crouting.set('t/:key/', { key: curt.key }); + render_show_toprow(); + + const main = uiu.qs('.main'); + uiu.empty(main); + + const meta_div = uiu.el(main, 'div', 'metadata_container'); + + + if(curt.tabletoperator_enabled) { + uiu.el(meta_div, 'div', 'unassigned_tableoperators_container'); + } + uiu.el(meta_div, 'div', 'umpire_container'); + render_announcement_formular(meta_div); + + + render_enable_announcements(meta_div, curt.locations); + + + const meta_right_div = uiu.el(meta_div, 'div', 'metadata_right_container'); + + const meta_right_top_div = uiu.el(meta_right_div, 'div', 'metadata_right_top_container'); + + render_enable_location_courts(meta_right_top_div, curt.locations); + render_automation_controls(meta_right_top_div); + + const errors_scroll_left_div = uiu.el(meta_right_div, 'div', 'errors_scroll_left'); + + uiu.el(errors_scroll_left_div, 'div', 'errors'); + + cmatch.prepare_render(curt); + + + uiu.el(main, 'div', 'courts_container'); + uiu.el(main, 'div', 'unassigned_container'); + const match_create_container = uiu.el(main, 'div'); + cmatch.render_create(match_create_container); + uiu.el(main, 'div', 'finished_container'); + + _show_render_matches(); + + _show_render_tabletoperators(); + _show_render_umpires(); + } + _route_single(/t\/([a-z0-9]+)\/$/, ui_show, change.default_handler(_update_all_ui_elements, { + score: update_score, + court_current_match: update_current_match, + update_player_status: update_player_status, + match_edit: update_match, + match_remove: remove_match, + normalization_removed: remove_normalization, + normalization_add: add_normalization, + advertisement_removed: remove_advertisement, + advertisement_add: add_advertisement, + tabletoperator_add: tabletoperator_add, + tabletoperator_moved_up: tabletoperator_moved_up, + tabletoperator_moved_down: tabletoperator_moved_down, + tabletoperator_removed: tabletoperator_removed, + btp_status: btp_status_changed, + ticker_status: ticker_status_changed, + })); + + function render_settings(target) { + const settings_div = uiu.el(target, 'div', 'metadata_right_container_2'); + uiu.el(settings_div, 'h3', {}, 'Turnier-Einstellungen'); + + const settings_table = uiu.el(settings_div, 'table'); + var tr = uiu.el(settings_table, 'tr'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'status_label', 'BTS'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'status status_connected',''); + var td = uiu.el(tr, 'td'); + const settings_btn = uiu.el(td, 'button', 'tournament_settings_link vlink', ci18n('edit tournament')); + settings_btn.addEventListener('click', ui_edit); + + var tr = uiu.el(settings_table, 'tr'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'btp_status_label', 'BTP'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'btp_status', ''); + btp_status_changed({ val: curt.btp_status }); + var td = uiu.el(tr, 'td'); + if (curt.btp_enabled) { + const btp_fetch_btn = uiu.el(td, 'button', 'tournament_btp_fetch vlink', ci18n('update from BTP')); + btp_fetch_btn.addEventListener('click', ui_btp_fetch); + } + var tr = uiu.el(settings_table, 'tr'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'ticker_status_label', 'Ticker'); + var td = uiu.el(tr, 'td'); + uiu.el(td, 'div', 'ticker_status', ''); + ticker_status_changed({ val: curt.ticker_status }); + var td = uiu.el(tr, 'td'); + if (curt.ticker_enabled) { + const ticker_push_btn = uiu.el(td, 'button', 'tournament_ticker_push vlink', ci18n('update ticker')); + ticker_push_btn.addEventListener('click', ui_ticker_push); + } + } + + function update_metadata_settings() { + if (current_view !== 'show') { + return; + } + render_show_toprow(); + } + + function btp_status_changed(c) { + set_service_status('btp_status', c); + update_btp_next_fetch_countdown(); + } + function ticker_status_changed(c) { + set_service_status('ticker_status', c); + } + + function bts_status_changed(c) { + set_service_status('status', c); + } + + function set_service_status(service_id, c) { + if (c && c.val) { + if (curt) { + curt[service_id] = c.val; + } + uiu.qsEach('.' + service_id, (div_el) => { + div_el.className = service_id + ' status_' + c.val.status; + div_el.title = c.val.message; + }); + if (service_id !== 'btp_status') { + update_service_badge(service_id, c); + } + } + } + + function _upload_logo(e) { + const input = e.target; + if (!input.files.length) return; + + const reader = new FileReader(); + reader.readAsDataURL(input.files[0]); + reader.onload = () => { + send_with_live_status({ + type: 'tournament_upload_logo', + tournament_key: curt.key, + data_url: reader.result, + name: e.target.files[0].name, + }, (err) => { + if (err) { + return cerror.net(err); + }` + input.closest('form').reset();` + }); + }; + reader.onerror = (e) => { + alert('Failed to upload: ' + e); + }; + } + + function ui_edit() { + current_view = 'edit'; + crouting.set('t/:key/edit', { key: curt.key }); + toprow.set([{ + label: ci18n('Tournaments'), + func: ui_list, + }, { + label: curt.name || curt.key, + func: ui_show, + 'class': 'ct_name', + }, { + label: ci18n('edit tournament'), + func: ui_edit, + }]); + + const main = uiu.qs('.main'); + uiu.empty(main); + + const form = uiu.el(main, 'div', 'tournament_settings'); + let input = {}; + + // tournament-div################################################################################## + { + const tournament_div = uiu.el(form, 'div', 'settings'); + uiu.el(tournament_div, 'h2', 'edit', ci18n('tournament:edit:tournament')); + + const key_label = uiu.el(tournament_div, 'label'); + uiu.el(key_label, 'span', {}, ci18n('tournament:edit:id')); + uiu.el(key_label, 'input', { + type: 'text', + name: 'key', + readonly: 'readonly', + disabled: 'disabled', + title: 'Can not be changed', + 'class': 'uneditable', + value: curt.key, + }); + + const name_label = uiu.el(tournament_div, 'label'); + uiu.el(name_label, 'span', {}, ci18n('tournament:edit:name')); + input.name = uiu.el(name_label, 'input', { + type: 'text', + name: 'name', + required: 'required', + value: curt.name || curt.key, + 'class': 'ct_name', + }); + bind_live_prop(input.name, 'name', { event_name: 'blur' }); + + + const name_tguid = uiu.el(tournament_div, 'label'); + uiu.el(name_tguid, 'span', {}, ci18n('tournament:edit:tguid')); + input.tguid = uiu.el(name_tguid, 'input', { + type: 'text', + name: 'tguid', + value: curt.tguid ? curt.tguid : "", + 'class': 'ct_tguid', + }); + bind_live_prop(input.tguid, 'tguid', { event_name: 'blur' }); + + // Tournament language selection + const language_label = uiu.el(tournament_div, 'label'); + uiu.el(language_label, 'span', {}, ci18n('tournament:edit:language')); + const language_select = uiu.el(language_label, 'select', { + name: 'language', + required: 'required', + }); + const all_langs = ci18n.get_all_languages(); + uiu.el(language_select, 'option', { value: 'auto' }, ci18n('tournament:edit:language:auto')); + for (const l of all_langs) { + const l_attrs = { + value: l._code, + }; + if (l._code === curt.language) { + l_attrs.selected = 'selected'; + } + uiu.el(language_select, 'option', l_attrs, l._name); + } + input.language = language_select; + bind_live_prop(input.language, 'language'); + + // Team competition? + const is_team_label = uiu.el(tournament_div, 'label'); + uiu.el(is_team_label, 'span', {}, ci18n('tournament:edit:tournament:type')); + const is_team_attrs = { + type: 'checkbox', + name: 'is_team', + }; + if (curt.is_team) { + is_team_attrs.checked = 'checked'; + } + + input.is_team = uiu.el(is_team_label, 'input', is_team_attrs); + uiu.el(is_team_label, 'span', {}, ci18n('team competition')); + bind_live_prop(input.is_team, 'is_team'); + + // Nation competition? + const is_nation_competition_label = uiu.el(tournament_div, 'label'); + const is_nation_competition_attrs = { + type: 'checkbox', + name: 'is_nation_competition', + }; + if (curt.is_nation_competition) { + is_nation_competition_attrs.checked = 'checked'; + } + + uiu.el(is_nation_competition_label, 'span', {}, ''); + input.is_nation_competition = uiu.el(is_nation_competition_label, 'input', is_nation_competition_attrs); + uiu.el(is_nation_competition_label, 'span', {}, ci18n('nation competition')); + bind_live_prop(input.is_nation_competition, 'is_nation_competition'); + } + + // btp-connection-div################################################################################## + { + const btp_connection_div = uiu.el(form, 'div', 'settings'); + uiu.el(btp_connection_div, 'h2', 'edit', ci18n('tournament:edit:btp_connection')); + + // BTP + const btp_fieldset = uiu.el(btp_connection_div, 'fieldset'); + const btp_enabled_label = uiu.el(btp_fieldset, 'label'); + const ba_attrs = { + type: 'checkbox', + name: 'btp_enabled', + }; + if (curt.btp_enabled) { + ba_attrs.checked = 'checked'; + } + input.btp_enabled = uiu.el(btp_enabled_label, 'input', ba_attrs); + uiu.el(btp_enabled_label, 'span', {}, ci18n('tournament:edit:btp:enabled')); + bind_live_prop(input.btp_enabled, 'btp_enabled'); + + const btp_autofetch_enabled_label = uiu.el(btp_fieldset, 'label'); + const bae_attrs = { + type: 'checkbox', + name: 'btp_autofetch_enabled', + }; + if (curt.btp_autofetch_enabled) { + bae_attrs.checked = 'checked'; + } + input.btp_autofetch_enabled = uiu.el(btp_autofetch_enabled_label, 'input', bae_attrs); + uiu.el(btp_autofetch_enabled_label, 'span', {}, ci18n('tournament:edit:btp:autofetch_enabled')); + bind_live_prop(input.btp_autofetch_enabled, 'btp_autofetch_enabled'); + + const btp_readonly_label = uiu.el(btp_fieldset, 'label'); + const bro_attrs = { + type: 'checkbox', + name: 'btp_readonly', + }; + if (curt.btp_readonly) { + bro_attrs.checked = 'checked'; + } + if (!curt['btp_autofetch_timeout_intervall']) { + curt['btp_autofetch_timeout_intervall'] = 30000; + } + input.btp_autofetch_timeout_intervall = create_input(curt, "number", btp_connection_div, 'btp_autofetch_timeout_intervall') + + input.btp_readonly = uiu.el(btp_readonly_label, 'input', bro_attrs); + uiu.el(btp_readonly_label, 'span', {}, ci18n('tournament:edit:btp:readonly')); + bind_live_prop(input.btp_readonly, 'btp_readonly'); + + const btp_ip_label = uiu.el(btp_fieldset, 'label'); + uiu.el(btp_ip_label, 'span', {}, ci18n('tournament:edit:btp:ip')); + input.btp_ip = uiu.el(btp_ip_label, 'input', { + type: 'text', + name: 'btp_ip', + value: (curt.btp_ip || ''), + }); + bind_live_prop(input.btp_ip, 'btp_ip', { event_name: 'blur' }); + + const btp_password_label = uiu.el(btp_fieldset, 'label'); + uiu.el(btp_password_label, 'span', {}, ci18n('tournament:edit:btp:password')); + input.btp_password = uiu.el(btp_password_label, 'input', { + type: 'text', + name: 'btp_password', + value: (curt.btp_password || ''), + }); + bind_live_prop(input.btp_password, 'btp_password', { event_name: 'blur' }); + + // BTP timezone + const btp_timezone_label = uiu.el(btp_fieldset, 'label'); + uiu.el(btp_timezone_label, 'span', {}, ci18n('tournament:edit:btp:timezone')); + const btp_timezone_select = uiu.el(btp_timezone_label, 'select', { + name: 'btp_timezone', + }); + uiu.el( + btp_timezone_select, 'option', { value: 'system' }, + ci18n('tournament:edit:btp:system timezone', { tz: curt.system_timezone })); + let marked = false; + for (const tz of timezones.ALL_TIMEZONES) { + const attrs = { + value: tz, + } + + if ((tz === curt.btp_timezone) && !marked) { + marked = true; + attrs.selected = 'selected'; + } + + uiu.el(btp_timezone_select, 'option', attrs, tz); + } + input.btp_timezone = btp_timezone_select; + bind_live_prop(input.btp_timezone, 'btp_timezone'); + } + + // tournament-flow-div################################################################################## + { + const tournament_flow_div = uiu.el(form, 'div', 'settings'); + uiu.el(tournament_flow_div, 'h2', 'edit', ci18n('tournament:edit:tournament_flow')); + // Warmup Timer + if (!curt.warmup_ready) { + curt.warmup_ready = 150; + } + + if (!curt.warmup_start) { + curt.warmup_start = 180; + } + + var warmup_options = [['bwf-2016', 90, 120, true], + ['legacy', 120, 120, true], + ['choise', curt.warmup_ready, curt.warmup_start, false], + ['call-down', curt.warmup_ready, curt.warmup_start, false], + ['call-up', 0, 0, true], + ['none', 0, 0, true]]; + + var last_selected_warmup = warmup_options[0]; + + const warmup_timer_label = uiu.el(tournament_flow_div, 'label'); + uiu.el(warmup_timer_label, 'span', {}, ci18n('tournament:edit:warmup_timer_behavior')); + const warmup_timer_select = uiu.el(warmup_timer_label, 'select', { + name: 'warmup', + }); + uiu.el(warmup_timer_select, 'option', { value: warmup_options[0][0] }, ci18n('tournament:edit:warmup_timer_behavior:' + warmup_options[0][0]), { wo: warmup_options[0][0] }); + let warmup_marked = false; + input.warmup = warmup_timer_select; + + const warmup_ready = uiu.el(tournament_flow_div, 'label'); + uiu.el(warmup_ready, 'span', {}, ci18n('tournament:edit:warmup_ready')); + var warmup_ready_input = uiu.el(warmup_ready, 'input', { + type: 'number', + name: 'warmup_ready', + required: 'required', + disabled: warmup_options[0][3], + value: warmup_options[0][1], + }); + input.warmup_ready = warmup_ready_input; + bind_live_prop(input.warmup_ready, 'warmup_ready', { + get_value: input_el => Number(input_el.value), + }); + + const warmup_start = uiu.el(tournament_flow_div, 'label'); + uiu.el(warmup_start, 'span', {}, ci18n('tournament:edit:warmup_start')); + var warmup_start_input = uiu.el(warmup_start, 'input', { + type: 'number', + name: 'warmup_start', + required: 'required', + disabled: warmup_options[0][3], + value: warmup_options[0][2], + }); + input.warmup_start = warmup_start_input; + bind_live_prop(input.warmup_start, 'warmup_start', { + get_value: input_el => Number(input_el.value), + }); + + for (const wo of warmup_options.slice(1)) { + const attrs = { + value: wo[0], + } + + if ((wo[0] === curt.warmup) && !warmup_marked) { + warmup_marked = true; + attrs.selected = 'selected'; + + warmup_ready_input.value = wo[1]; + warmup_ready_input.disabled = wo[3]; + warmup_start_input.value = wo[2]; + warmup_start_input.disabled = wo[3]; + + last_selected_warmup = wo; + } + + uiu.el(warmup_timer_select, 'option', attrs, ci18n('tournament:edit:warmup_timer_behavior:' + wo[0])); + } + + warmup_timer_select.onchange = function () { + if (!last_selected_warmup[3]) { + for (const wo of warmup_options) { + if (!wo[3]) { + wo[1] = warmup_ready_input.value; + wo[2] = warmup_start_input.value; + } + } + } + + for (const wo of warmup_options) { + if (warmup_timer_select.value == wo[0]) { + warmup_ready_input.value = wo[1]; + warmup_ready_input.disabled = wo[3]; + warmup_start_input.value = wo[2]; + warmup_start_input.disabled = wo[3]; + + last_selected_warmup = wo; + } + } + send_single_prop('warmup', warmup_timer_select.value, function(err) { + if (err) { + return cerror.net(err); + } + }); + send_single_prop('warmup_ready', Number(warmup_ready_input.value), function(err) { + if (err) { + return cerror.net(err); + } + }); + send_single_prop('warmup_start', Number(warmup_start_input.value), function(err) { + if (err) { + return cerror.net(err); + } + }); + }; + + const bts_fieldset = uiu.el(tournament_flow_div, 'fieldset', 'automation_group_box'); + const bts_legend = uiu.el(bts_fieldset, 'legend'); + input.call_preparation_matches_automatically_enabled = uiu.el(bts_legend, 'input', { + type: 'checkbox', + name: 'call_preparation_matches_automatically_enabled', + }); + if (curt.call_preparation_matches_automatically_enabled) { + input.call_preparation_matches_automatically_enabled.checked = true; + } + uiu.el(bts_legend, 'span', {}, ci18n('tournament:edit:call_preparation_matches_automatically_enabled')); + bind_live_prop(input.call_preparation_matches_automatically_enabled, 'call_preparation_matches_automatically_enabled'); + input.preparation_successor_rally_count = create_numeric_input(curt, bts_fieldset, 'preparation_successor_rally_count', 1, 100, 11, 1); + input.preparation_call_player_pause_expired_enabled = create_checkbox(curt, bts_fieldset, 'preparation_call_player_pause_expired_enabled', 'automation_suboption_checkbox'); + input.preparation_call_technical_officials_available_enabled = create_checkbox(curt, bts_fieldset, 'preparation_call_technical_officials_available_enabled', 'automation_suboption_checkbox'); + input.preparation_call_technical_officials_available_hint = uiu.el(bts_fieldset, 'div', 'automation_suboption_hint'); + { + const rule = create_rule_limit_input(curt, bts_fieldset, 'preparation_call_time_limit_before_scheduled_enabled', 'preparation_call_time_limit_before_scheduled_minutes', 30, 0, 180, 1, 'tournament:edit:minutes'); + input.preparation_call_time_limit_before_scheduled_enabled = rule.enabled_input; + input.preparation_call_time_limit_before_scheduled_minutes = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, bts_fieldset, 'preparation_call_block_ahead_limit_enabled', 'preparation_call_block_ahead_limit', 1, 0, 10, 1, null); + input.preparation_call_block_ahead_limit_enabled = rule.enabled_input; + input.preparation_call_block_ahead_limit = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, bts_fieldset, 'preparation_call_time_ahead_of_frontier_enabled', 'preparation_call_time_ahead_of_frontier_minutes', 30, 0, 180, 1, 'tournament:edit:minutes'); + input.preparation_call_time_ahead_of_frontier_enabled = rule.enabled_input; + input.preparation_call_time_ahead_of_frontier_minutes = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, bts_fieldset, 'preparation_call_matches_ahead_of_frontier_enabled', 'preparation_call_matches_ahead_of_frontier_limit', 1, 0, 50, 1, null); + input.preparation_call_matches_ahead_of_frontier_enabled = rule.enabled_input; + input.preparation_call_matches_ahead_of_frontier_limit = rule.value_input; + } + const free_courts_fieldset = uiu.el(tournament_flow_div, 'fieldset', 'automation_group_box'); + const free_courts_legend = uiu.el(free_courts_fieldset, 'legend'); + input.call_next_possible_scheduled_match_in_preparation = uiu.el(free_courts_legend, 'input', { + type: 'checkbox', + name: 'call_next_possible_scheduled_match_in_preparation', + }); + if (curt.call_next_possible_scheduled_match_in_preparation) { + input.call_next_possible_scheduled_match_in_preparation.checked = true; + } + uiu.el(free_courts_legend, 'span', {}, ci18n('tournament:edit:call_next_possible_scheduled_match_in_preparation')); + bind_live_prop(input.call_next_possible_scheduled_match_in_preparation, 'call_next_possible_scheduled_match_in_preparation'); + { + const rule = create_rule_limit_input(curt, free_courts_fieldset, 'call_on_court_only_preparation_enabled', 'call_on_court_only_preparation_minutes', 0, 0, 180, 1, 'tournament:edit:minutes'); + input.call_on_court_only_preparation_enabled = rule.enabled_input; + input.call_on_court_only_preparation_minutes = rule.value_input; + } + input.call_on_court_participant_readiness_mode = create_rule_select_input(curt, free_courts_fieldset, 'call_on_court_participant_readiness_mode', ['disabled', 'checked_in', 'pause_expired'], () => { + if (curt.call_on_court_player_pause_expired_enabled === true) { + return 'pause_expired'; + } + return 'disabled'; + }); + input.call_on_court_technical_officials_mode = create_rule_select_input(curt, free_courts_fieldset, 'call_on_court_technical_officials_mode', ['disabled', 'checked_in', 'available'], () => 'disabled'); + input.call_on_court_require_official_space_enabled = create_checkbox(curt, input.call_on_court_technical_officials_mode.rule_box, 'call_on_court_require_official_space_enabled'); + input.call_on_court_technical_officials_hint = uiu.el(input.call_on_court_technical_officials_mode.rule_box, 'div', 'automation_suboption_hint'); + { + const rule = create_rule_limit_input(curt, free_courts_fieldset, 'call_on_court_time_limit_before_scheduled_enabled', 'call_on_court_time_limit_before_scheduled_minutes', 30, 0, 180, 1, 'tournament:edit:minutes'); + input.call_on_court_time_limit_before_scheduled_enabled = rule.enabled_input; + input.call_on_court_time_limit_before_scheduled_minutes = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, free_courts_fieldset, 'call_on_court_block_ahead_limit_enabled', 'call_on_court_block_ahead_limit', 1, 0, 10, 1, null); + input.call_on_court_block_ahead_limit_enabled = rule.enabled_input; + input.call_on_court_block_ahead_limit = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, free_courts_fieldset, 'call_on_court_time_ahead_of_frontier_enabled', 'call_on_court_time_ahead_of_frontier_minutes', 30, 0, 180, 1, 'tournament:edit:minutes'); + input.call_on_court_time_ahead_of_frontier_enabled = rule.enabled_input; + input.call_on_court_time_ahead_of_frontier_minutes = rule.value_input; + } + { + const rule = create_rule_limit_input(curt, free_courts_fieldset, 'call_on_court_matches_ahead_of_frontier_enabled', 'call_on_court_matches_ahead_of_frontier_limit', 1, 0, 50, 1, null); + input.call_on_court_matches_ahead_of_frontier_enabled = rule.enabled_input; + input.call_on_court_matches_ahead_of_frontier_limit = rule.value_input; + } + + const tablet_fieldset = uiu.el(tournament_flow_div, 'fieldset', 'automation_group_box'); + const tablet_legend = uiu.el(tablet_fieldset, 'legend'); + input.tabletoperator_enabled = uiu.el(tablet_legend, 'input', { + type: 'checkbox', + name: 'tabletoperator_enabled', + }); + if (curt.tabletoperator_enabled) { + input.tabletoperator_enabled.checked = true; + } + uiu.el(tablet_legend, 'span', {}, ci18n('tournament:edit:tabletoperator_enabled')); + bind_live_prop(input.tabletoperator_enabled, 'tabletoperator_enabled'); + input.tabletoperator_with_umpire_enabled = create_checkbox(curt, tablet_fieldset, 'tabletoperator_with_umpire_enabled'); + input.tabletoperator_winner_of_quaterfinals_enabled = create_checkbox(curt, tablet_fieldset, 'tabletoperator_winner_of_quaterfinals_enabled'); + input.tabletoperator_use_manual_counting_boards_enabled = create_checkbox(curt, tablet_fieldset, 'tabletoperator_use_manual_counting_boards_enabled'); + input.tabletoperator_split_doubles = create_checkbox(curt, tablet_fieldset, 'tabletoperator_split_doubles'); + input.tabletoperator_with_state_enabled = create_checkbox(curt, tablet_fieldset, 'tabletoperator_with_state_enabled'); + input.tabletoperator_with_state_from_match_enabled = create_checkbox(curt, tablet_fieldset, 'tabletoperator_with_state_from_match_enabled'); + input.tabletoperator_set_break_after_tabletservice = create_checkbox(curt, tablet_fieldset, 'tabletoperator_set_break_after_tabletservice'); + + if (!curt.tabletoperator_break_seconds) { + curt.tabletoperator_break_seconds = 300; + } + input.tabletoperator_break_seconds = create_input(curt, "number", tablet_fieldset, 'tabletoperator_break_seconds') + + } + + + // scoring-formats-div############################################################################## + { + const scoring_div = uiu.el(form, "div", "settings"); + scoring_formats_main = scoring_div; + render_scoring_formats(scoring_div); + render_stages_scoring_formats(scoring_div) + } + + + + // call-div################################################################################## + { + const call_div = uiu.el(form, 'div', 'settings'); + uiu.el(call_div, 'h2', 'edit', ci18n('tournament:edit:calls')); + + const announcements_fieldset = uiu.el(call_div, 'fieldset'); + input.annoncement_include_event = create_checkbox(curt, announcements_fieldset, 'annoncement_include_event'); + input.annoncement_include_round = create_checkbox(curt, announcements_fieldset, 'annoncement_include_round'); + input.annoncement_include_matchnumber = create_checkbox(curt, announcements_fieldset, 'annoncement_include_matchnumber'); + input.preparation_meetingpoint_enabled = create_checkbox(curt, announcements_fieldset, 'preparation_meetingpoint_enabled'); + input.preparation_tabletoperator_setup_enabled = create_checkbox(curt, announcements_fieldset, 'preparation_tabletoperator_setup_enabled'); + + input.announcement_speed = create_numeric_input(curt, call_div, 'announcement_speed', 0.8, 1.3, 1.05, 0.01); + input.announcement_pause_time_ms = create_numeric_input(curt, call_div, 'announcement_pause_time_ms', 0.0, 5.0, 2.0, 0.1); + + render_normalisation_values(uiu.el(call_div, 'div','normalizations_values_div')); + + + } + + // upcoming-div ################################################################################################### + { + const upcoming_div = uiu.el(form, 'div', 'settings'); + uiu.el(upcoming_div, 'h2', 'edit', ci18n('tournament:edit:upcoming_matches_settings')); + + const upcoming_fieldset = uiu.el(upcoming_div, 'fieldset'); + input.upcoming_animation_speed = create_numeric_input(curt, upcoming_fieldset, 'upcoming_matches_animation_speed', 0, 10, 2, 1); + input.upcoming_animation_pause = create_numeric_input(curt, upcoming_fieldset, 'upcoming_matches_animation_pause', 1, 20, 4, 1); + input.upcoming_matches_max_count = create_numeric_input(curt, upcoming_fieldset, 'upcoming_matches_max_count', 10, 50, 15, 1); + input.self_check_in_called_overlay_duration_ms = create_duration_seconds_input(curt, upcoming_fieldset, 'self_check_in_called_overlay_duration_ms', 1, 60, 12, 0.5); + } + + // officials_host ###################################################################################################### + const officials_host = uiu.el(form, 'div', { id: 'officials_host' }); + update_official_tables(officials_host); // initial + später auch für Updates + + + + // devices-div################################################################################## + { + const devices_div = uiu.el(form, 'div', 'settings'); + uiu.el(devices_div, 'h2', 'edit', ci18n('tournament:edit:devices')); + + render_logo_preview(devices_div); + + const default_display_fieldset = uiu.el(devices_div, 'fieldset'); + // Default display + const cur_dm_style = curt.dm_style || 'international'; + const dm_style_label = uiu.el(default_display_fieldset, 'label'); + uiu.el(dm_style_label, 'span', {}, ci18n('tournament:edit:dm_style')); + const dm_style_select = uiu.el(dm_style_label, 'select', { + name: 'dm_style', + required: 'required', + }); + const all_dm_styles = displaymode.ALL_STYLES; + for (const s of all_dm_styles) { + const s_attrs = { + value: s, + }; + if (s === cur_dm_style) { + s_attrs.selected = 'selected'; + } + uiu.el(dm_style_select, 'option', s_attrs, s); + } + input.dm_style = dm_style_select; + bind_live_prop(input.dm_style, 'dm_style'); + + const displaysettings_style_label = uiu.el(default_display_fieldset, 'label'); + uiu.el(displaysettings_style_label, 'span', {}, ci18n('tournament:edit:displaysettings_general')); + + input.displaysettings_general = createGeneralDisplaySettingsSelectBox(displaysettings_style_label, curt.displaysettings_general ? curt.displaysettings_general : "default"); + bind_live_prop(input.displaysettings_general, 'displaysettings_general'); + + const general_displaysettings_div = uiu.el(devices_div, 'div', 'general_displaysettings'); + render_general_displaysettings(general_displaysettings_div); + render_displaysettings(devices_div); + } + + + // advertisement-div################################################################################## + { + const advertisement_div = uiu.el(form, 'div', 'settings'); + render_advertisements(advertisement_div); + } + + + + // location-div################################################################################## + { + const location_div = uiu.el(form, 'div', 'settings'); + render_locations(location_div); + render_courts(location_div); + } + + // ticker-connection-div################################################################################## + { + const ticker_div = uiu.el(form, 'div', 'settings'); + uiu.el(ticker_div, 'h2', 'edit', ci18n('tournament:edit:ticker_connection')); + + const ticker_fieldset = uiu.el(ticker_div, 'fieldset'); + const ticker_enabled_label = uiu.el(ticker_fieldset, 'label'); + const te_attrs = { + type: 'checkbox', + name: 'ticker_enabled', + }; + if (curt.ticker_enabled) { + te_attrs.checked = 'checked'; + } + input.ticker_enabled = uiu.el(ticker_enabled_label, 'input', te_attrs); + uiu.el(ticker_enabled_label, 'span', {}, ci18n('tournament:edit:ticker_enabled')); + bind_live_prop(input.ticker_enabled, 'ticker_enabled'); + + const ticker_url_label = uiu.el(ticker_fieldset, 'label'); + uiu.el(ticker_url_label, 'span', {}, ci18n('tournament:edit:ticker_url')); + input.ticker_url = uiu.el(ticker_url_label, 'input', { + type: 'text', + name: 'ticker_url', + value: (curt.ticker_url || ''), + }); + bind_live_prop(input.ticker_url, 'ticker_url', { event_name: 'blur' }); + + const ticker_password_label = uiu.el(ticker_fieldset, 'label'); + uiu.el(ticker_password_label, 'span', {}, ci18n('tournament:edit:ticker_password')); + input.ticker_password = uiu.el(ticker_password_label, 'input', { + type: 'text', + name: 'ticker_password', + value: (curt.ticker_password || ''), + }); + bind_live_prop(input.ticker_password, 'ticker_password', { event_name: 'blur' }); + } + + // save-div################################################################################## + { + const save_div = uiu.el(form, 'div', 'settings'); + uiu.el(save_div, 'h2', 'edit', ci18n('tournament:edit')); + live_settings_pending_requests = 0; + live_settings_status_el = uiu.el(save_div, 'div', { + class: 'live_settings_status live_settings_status_saved', + }, ci18n('tournament:edit:live_status:saved')); + + const back_btn = uiu.el(save_div, 'button', { + role: 'button', + }, ci18n('Back')); + back_btn.addEventListener('click', () => { + ui_show(); + }); + } + update_edit_dependencies(); + } + _route_single(/t\/([a-z0-9]+)\/edit$/, ui_edit, change.default_handler(_update_all_ui_elements_edit, { + update_general_displaysettings: update_general_displaysettings, + update_player_status: update_player_status, + })); + + function update_scoring_formats() { + if (!scoring_formats_main) { + if (typeof debug !== "undefined" && debug?.log) { + debug.log("update_scoring_formats: main container not initialized"); + } + return; + } + + // kompletten Bereich leeren + while (scoring_formats_main.firstChild) { + scoring_formats_main.removeChild(scoring_formats_main.firstChild); + } + + // vollständig neu rendern + render_scoring_formats(scoring_formats_main); + render_stages_scoring_formats(scoring_formats_main); + } + + function format_duration_ms(durationMs) { + const duration = Number(durationMs); + if (!Number.isFinite(duration) || duration < 0) { + return "—"; + } + if (duration === 0) { + return "0 s"; + } + return `${Math.round(duration / 1000)} s`; + } + + function format_set_rule_summary(setPoints) { + if (!setPoints) { + return "—"; + } + + const endPoints = setPoints.end_points ?? "—"; + const maxPoints = setPoints.max_points ?? "—"; + return `${endPoints} / ${maxPoints}`; + } + + function parse_nullable_number(value) { + if (value === undefined || value === null || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + function duration_ms_to_seconds(value) { + const duration = parse_nullable_number(value); + if (duration === null) { + return ""; + } + return duration / 1000; + } + + function duration_seconds_to_ms(value) { + const duration = parse_nullable_number(value); + if (duration === null) { + return null; + } + return duration * 1000; + } + + function is_break_in_set_enabled(setPoints) { + if (!setPoints) { + return false; + } + if (typeof setPoints.interval_enabled === "boolean") { + return setPoints.interval_enabled; + } + return ( + setPoints.interval_at !== null && + setPoints.interval_at !== undefined && + setPoints.interval_duration_ms !== null && + setPoints.interval_duration_ms !== undefined + ); + } + + function clone_scoring_formats() { + const scoringFormats = curt?.scoring_formats || { formats: [], default_id: null }; + return structuredClone(scoringFormats); + } + + function _cancel_ui_edit_scoring_format() { + const dlg = document.querySelector('.scoring_format_edit_dialog'); + if (!dlg) { + return; + } + cbts_utils.esc_stack_pop(); + uiu.remove(dlg); + } + + function close_scoring_format_dialog_if_open(scoringFormatId, reason_i18n_key) { + const dlg = document.querySelector('.scoring_format_edit_dialog'); + if (!dlg) { + return false; + } + const open_id = dlg.getAttribute('data-scoring-format-id'); + if (typeof scoringFormatId !== 'undefined' && scoringFormatId !== null && Number(open_id) !== Number(scoringFormatId)) { + return false; + } + _cancel_ui_edit_scoring_format(); + if (reason_i18n_key) { + const reason_text = ci18n(reason_i18n_key); + if (!set_live_settings_status_message(reason_text, 'error')) { + cerror.silent(reason_text); + } + } + return true; + } + + function create_scoring_format_field(parent, label, name, value, type = "text", attrs = {}) { + const row = uiu.el(parent, 'label', 'scoring_format_edit_row'); + uiu.el(row, 'span', {}, label); + return uiu.el(row, 'input', Object.assign({ + type, + name, + value: value ?? '', + }, attrs)); + } + + function create_scoring_format_checkbox(parent, label, name, checked) { + const row = uiu.el(parent, 'label', 'scoring_format_edit_row'); + uiu.el(row, 'span', {}, label); + const attrs = { + type: 'checkbox', + name, + }; + if (checked) { + attrs.checked = 'checked'; + } + return uiu.el(row, 'input', attrs); + } + + function is_scoring_value_editable(setPoints, fieldName) { + return !!(setPoints && setPoints[`${fieldName}_editable`]); + } + + function render_scoring_format_edit_section(parent, prefix, title, setPoints) { + const fieldset = uiu.el(parent, 'fieldset', 'scoring_format_edit_section'); + uiu.el(fieldset, 'legend', {}, title); + const endPointAttrs = { min: 1, step: 1 }; + if (!is_scoring_value_editable(setPoints, "end_points")) { + endPointAttrs.disabled = 'disabled'; + } else { + endPointAttrs.required = 'required'; + } + const maxPointAttrs = { min: 1, step: 1 }; + if (!is_scoring_value_editable(setPoints, "max_points")) { + maxPointAttrs.disabled = 'disabled'; + } else { + maxPointAttrs.required = 'required'; + } + const endPointsInput = create_scoring_format_field(fieldset, ci18n("tournament:edit:scoring_formats:end_points_label"), `${prefix}_end_points`, setPoints?.end_points, "number", endPointAttrs); + const maxPointsInput = create_scoring_format_field(fieldset, ci18n("tournament:edit:scoring_formats:max_points"), `${prefix}_max_points`, setPoints?.max_points, "number", maxPointAttrs); + const hasBreakInSet = is_break_in_set_enabled(setPoints); + const breakEnabled = create_scoring_format_checkbox(fieldset, ci18n("tournament:edit:scoring_formats:break_in_set_enabled"), `${prefix}_break_in_set_enabled`, hasBreakInSet); + const intervalAtInput = create_scoring_format_field(fieldset, ci18n("tournament:edit:scoring_formats:interval_at"), `${prefix}_interval_at`, setPoints?.interval_at, "number", { min: 0, step: 1 }); + const intervalDurationInput = create_scoring_format_field(fieldset, `${ci18n("tournament:edit:scoring_formats:interval_duration")} (s)`, `${prefix}_interval_duration_s`, duration_ms_to_seconds(setPoints?.interval_duration_ms), "number", { min: 0, step: 1 }); + create_scoring_format_field(fieldset, `${ci18n("tournament:edit:scoring_formats:break_before_set")} (s)`, `${prefix}_break_before_set_duration_s`, duration_ms_to_seconds(setPoints?.break_before_set_duration_ms), "number", { min: 0, step: 1 }); + + function normalizeScoreInputs() { + if (!endPointsInput.disabled) { + let endPoints = Number(endPointsInput.value); + if (!Number.isFinite(endPoints) || endPoints < 1) { + endPoints = Math.max(1, Number(setPoints?.end_points) || 1); + } + endPointsInput.value = String(endPoints); + if (!maxPointsInput.disabled) { + maxPointsInput.min = String(endPoints); + let maxPoints = Number(maxPointsInput.value); + if (!Number.isFinite(maxPoints) || maxPoints < endPoints) { + maxPoints = endPoints; + } + maxPointsInput.value = String(maxPoints); + } + } else if (!maxPointsInput.disabled) { + let maxPoints = Number(maxPointsInput.value); + const minValue = Math.max(1, Number(setPoints?.end_points) || 1); + maxPointsInput.min = String(minValue); + if (!Number.isFinite(maxPoints) || maxPoints < minValue) { + maxPointsInput.value = String(minValue); + } + } + } + + if (!endPointsInput.disabled) { + endPointsInput.addEventListener('input', normalizeScoreInputs); + endPointsInput.addEventListener('blur', normalizeScoreInputs); + } + if (!maxPointsInput.disabled) { + maxPointsInput.addEventListener('input', normalizeScoreInputs); + maxPointsInput.addEventListener('blur', normalizeScoreInputs); + } + normalizeScoreInputs(); + + function updateBreakInSetUi() { + const enabled = breakEnabled.checked; + intervalAtInput.disabled = !enabled; + intervalDurationInput.disabled = !enabled; + } + + breakEnabled.addEventListener('change', updateBreakInSetUi); + updateBreakInSetUi(); + } + + function scoring_format_from_form_data(baseFormat, data) { + const scoringFormat = structuredClone(baseFormat); + + function update_set_points(target, prefix) { + if (is_scoring_value_editable(target, "end_points")) { + target.end_points = Math.max(1, Number(data[`${prefix}_end_points`])); + } + if (is_scoring_value_editable(target, "max_points")) { + const minPoints = Math.max(1, Number(target.end_points)); + target.max_points = Math.max(minPoints, Number(data[`${prefix}_max_points`])); + } + const hasBreakInSet = !!data[`${prefix}_break_in_set_enabled`]; + target.interval_enabled = hasBreakInSet; + if (hasBreakInSet) { + target.interval_at = parse_nullable_number(data[`${prefix}_interval_at`]); + target.interval_duration_ms = duration_seconds_to_ms(data[`${prefix}_interval_duration_s`]); + } + target.break_before_set_duration_ms = duration_seconds_to_ms(data[`${prefix}_break_before_set_duration_s`]); + } + + update_set_points(scoringFormat.set_points, 'set_points'); + update_set_points(scoringFormat.last_set_points, 'last_set_points'); + return scoringFormat; + } + + function save_scoring_format(scoringFormatId, scoringFormat, callback) { + send_with_live_status({ + type: 'tournament_edit_scoring_format', + key: curt.key, + scoring_format: scoringFormat, + }, callback); + } + + function ui_edit_scoring_format(scoringFormatId) { + const scoringFormats = curt?.scoring_formats; + const baseFormat = structuredClone(utils.find((scoringFormats && scoringFormats.formats) || [], f => Number(f.id) === Number(scoringFormatId))); + if (!baseFormat) { + return; + } + + cbts_utils.esc_stack_push(_cancel_ui_edit_scoring_format); + + const body = uiu.qs('body'); + const dialogBg = uiu.el(body, 'div', 'dialog_bg scoring_format_edit_dialog', { + 'data-scoring-format-id': scoringFormatId, + }); + dialogBg.addEventListener('click', (e) => { + if (e.target === dialogBg) { + _cancel_ui_edit_scoring_format(); + } + }); + + const dialog = uiu.el(dialogBg, 'div', 'dialog'); + uiu.el(dialog, 'h3', {}, ci18n('tournament:edit:scoring_formats:dialog_title')); + + const form = uiu.el(dialog, 'form'); + const container = uiu.el(form, 'div', 'scoring_format_edit_container'); + uiu.el(container, 'div', 'hint', ci18n('tournament:edit:scoring_formats:dialog_hint')); + create_scoring_format_field(container, ci18n("tournament:edit:scoring_formats:name"), 'name', baseFormat.name, 'text', { disabled: 'disabled' }); + create_scoring_format_field(container, ci18n("tournament:edit:scoring_formats:num_sets"), 'numSets', baseFormat.numSets, 'number', { min: 1, step: 1, disabled: 'disabled' }); + render_scoring_format_edit_section(container, 'set_points', ci18n("tournament:edit:scoring_formats:regular_sets"), baseFormat.set_points); + render_scoring_format_edit_section(container, 'last_set_points', ci18n("tournament:edit:scoring_formats:last_set"), baseFormat.last_set_points); + + const buttons = uiu.el(form, 'div', { style: 'margin-top: 2em;' }); + uiu.el(buttons, 'button', { + 'class': 'match_save_button', + role: 'submit', + }, ci18n('Change')); + + form_utils.onsubmit(form, function(data) { + const scoringFormat = scoring_format_from_form_data(baseFormat, data); + save_scoring_format(scoringFormatId, scoringFormat, (err) => { + if (err) { + return cerror.net(err); + } + _cancel_ui_edit_scoring_format(); + }); + }); + + const cancelBtn = uiu.el(buttons, 'span', 'match_cancel_link vlink', ci18n('Cancel')); + cancelBtn.addEventListener('click', _cancel_ui_edit_scoring_format); + } + + function render_scoring_formats(main) { + uiu.el(main, "h2", "edit", ci18n("tournament:edit:scoring_formats")); + + const sf = curt?.scoring_formats || { formats: [], default_id: null }; + const formats = Array.isArray(sf.formats) ? sf.formats : []; + const defaultId = sf.default_id; + + const table = uiu.el(main, "table", "scoring_formats_table"); + const tbody = uiu.el(table, "tbody"); + + { + const tr = uiu.el(tbody, "tr"); + uiu.el(tr, "th", { class: "scoring_format_name_cell" }, ci18n("tournament:edit:scoring_formats:name")); + uiu.el(tr, "th", { class: "scoring_format_center_cell" }, ci18n("tournament:edit:scoring_formats:num_sets")); + uiu.el(tr, "th", { class: "scoring_format_type_cell" }, ci18n("tournament:edit:scoring_formats:type")); + uiu.el(tr, "th", { class: "scoring_format_center_cell" }, ci18n("tournament:edit:scoring_formats:end_max")); + uiu.el(tr, "th", { class: "scoring_format_center_cell" }, ci18n("tournament:edit:scoring_formats:interval_at")); + uiu.el(tr, "th", { class: "scoring_format_right_cell" }, ci18n("tournament:edit:scoring_formats:interval_duration")); + uiu.el(tr, "th", { class: "scoring_format_right_cell" }, ci18n("tournament:edit:scoring_formats:break_before_set")); + uiu.el(tr, "th", { class: "scoring_format_center_cell" }, ci18n("tournament:edit:scoring_formats:default")); + uiu.el(tr, "th", { class: "scoring_format_center_cell" }, ci18n("tournament:edit:scoring_formats:edit")); + } + + for (const [formatIndex, f] of formats.entries()) { + const rowClass = (formatIndex % 2 === 0) ? "scoring_formats_row_group_even" : "scoring_formats_row_group_odd"; + const regularTr = uiu.el(tbody, "tr", rowClass); + const lastTr = uiu.el(tbody, "tr", `scoring_formats_subrow ${rowClass}`); + const regularSetPoints = f?.set_points; + const lastSetPoints = f?.last_set_points; + const isDefault = Number(f.id) === Number(defaultId); + const canEdit = true; + + uiu.el(regularTr, "td", { rowspan: 2, class: "scoring_format_name_cell" }, f.name || ""); + uiu.el(regularTr, "td", { rowspan: 2, class: "scoring_format_center_cell" }, String(f.numSets ?? "")); + uiu.el(regularTr, "td", { class: "scoring_format_type_cell scoring_format_rule_cell" }, ci18n("tournament:edit:scoring_formats:type_regular")); + uiu.el(regularTr, "td", { class: "scoring_format_rule_cell scoring_format_center_cell" }, format_set_rule_summary(regularSetPoints)); + uiu.el(regularTr, "td", { class: "scoring_format_rule_cell scoring_format_center_cell" }, is_break_in_set_enabled(regularSetPoints) ? String(regularSetPoints.interval_at) : "—"); + uiu.el(regularTr, "td", { class: "scoring_format_rule_cell scoring_format_right_cell" }, is_break_in_set_enabled(regularSetPoints) ? format_duration_ms(regularSetPoints && regularSetPoints.interval_duration_ms) : "—"); + uiu.el(regularTr, "td", { class: "scoring_format_rule_cell scoring_format_right_cell" }, format_duration_ms(regularSetPoints && regularSetPoints.break_before_set_duration_ms)); + + const defTd = uiu.el(regularTr, "td", { rowspan: 2, class: "scoring_format_center_cell" }); + if (isDefault) { + uiu.el(defTd, "span", { + class: "default_scoring_format_badge", + title: ci18n("tournament:edit:scoring_formats:default"), + }, ci18n("tournament:edit:scoring_formats:default_badge")); + } else { + uiu.el(defTd, "span", { class: "default_scoring_format_badge default_scoring_format_badge_inactive" }, "—"); + } + + const actionsTd = uiu.el(regularTr, "td", { rowspan: 2, class: "scoring_format_center_cell" }); + const editBtn = uiu.el( + actionsTd, + "button", + { "data-scoring-format-id": f.id }, + ci18n("tournament:edit:scoring_formats:edit") + ); + + editBtn.addEventListener("click", (e) => { + const id = e.target.getAttribute("data-scoring-format-id"); + ui_edit_scoring_format(id); + }); + + uiu.el(lastTr, "td", { class: "scoring_format_type_cell scoring_format_rule_cell" }, ci18n("tournament:edit:scoring_formats:type_last")); + uiu.el(lastTr, "td", { class: "scoring_format_rule_cell scoring_format_center_cell" }, format_set_rule_summary(lastSetPoints)); + uiu.el(lastTr, "td", { class: "scoring_format_rule_cell scoring_format_center_cell" }, is_break_in_set_enabled(lastSetPoints) ? String(lastSetPoints.interval_at) : "—"); + uiu.el(lastTr, "td", { class: "scoring_format_rule_cell scoring_format_right_cell" }, is_break_in_set_enabled(lastSetPoints) ? format_duration_ms(lastSetPoints && lastSetPoints.interval_duration_ms) : "—"); + uiu.el(lastTr, "td", { class: "scoring_format_rule_cell scoring_format_right_cell" }, format_duration_ms(lastSetPoints && lastSetPoints.break_before_set_duration_ms)); + } + } + + function update_stages_scoring_formats() { + if (!scoring_formats_main) { + if (typeof debug !== "undefined" && debug?.log) { + debug.log("update_scoring_formats: main container not initialized"); + } + return; + } + + // kompletten Bereich leeren + while (scoring_formats_main.firstChild) { + scoring_formats_main.removeChild(scoring_formats_main.firstChild); + } + + // vollständig neu rendern + render_scoring_formats(scoring_formats_main); + render_stages_scoring_formats(scoring_formats_main); + } + + function render_stages_scoring_formats(main) { + const sf = curt?.scoring_formats || { formats: [], default_id: null }; + const defaultId = sf.default_id; + + // Build lookup: scoring_format_id -> scoring_format_name + const formatNameById = new Map(); + for (const f of sf.formats || []) { + formatNameById.set(Number(f.id), f.name || String(f.id)); + } + + const eventsPayload = curt?.events?.events || []; + const deviations = []; + + for (const ev of eventsPayload) { + const eventName = ev?.name || ""; + const stages = Array.isArray(ev?.stages) ? ev.stages : []; + + for (const st of stages) { + // Missing/null scoring_format => default + const stageSfId = + st && st.scoring_format !== undefined && st.scoring_format !== null + ? Number(st.scoring_format) + : null; + + if ( + stageSfId !== null && + defaultId !== null && + defaultId !== undefined && + stageSfId !== Number(defaultId) + ) { + deviations.push({ + event_name: eventName, + stage_name: st?.name || "", + scoring_format_id: stageSfId, + scoring_format_name: formatNameById.get(stageSfId) || String(stageSfId), + }); + } + } + } + + deviations.sort((a, b) => { + const e = (a.event_name || "").localeCompare(b.event_name || ""); + if (e) return e; + const s = (a.stage_name || "").localeCompare(b.stage_name || ""); + if (s) return s; + return (a.scoring_format_id || 0) - (b.scoring_format_id || 0); + }); + + uiu.el(main, "h3", "edit", "Abweichungen vom Default"); + + if (defaultId === null || defaultId === undefined) { + uiu.el( + main, + "div", + "hint", + "Kein Default-Scoring-Format gefunden (scoring_formats.default_id ist leer)." + ); + return; + } + + if (deviations.length === 0) { + uiu.el(main, "div", "hint", "Keine Stages weichen vom Default-Scoring-Format ab."); + return; + } + + const devTable = uiu.el(main, "table", "scoring_format_deviations_table"); + const devBody = uiu.el(devTable, "tbody"); + + { + const tr = uiu.el(devBody, "tr"); + uiu.el(tr, "th", {}, "Event"); + uiu.el(tr, "th", {}, "Stage"); + uiu.el(tr, "th", {}, "Verwendete Zählweise"); + } + + for (const d of deviations) { + const tr = uiu.el(devBody, "tr"); + uiu.el(tr, "td", {}, d.event_name); + uiu.el(tr, "td", {}, d.stage_name); + uiu.el(tr, "td", {}, `${d.scoring_format_name} (#${d.scoring_format_id})`); + } + } + + + + function render_normalisation_values(main) { + uiu.el(main, 'h2','edit', ci18n('tournament:edit:normalizations')); + + const display_table = uiu.el(main, 'table'); + const display_tbody = uiu.el(display_table, 'tbody'); + const tr = uiu.el(display_tbody, 'tr'); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:normalizations:origin')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:normalizations:replace')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:normalizations:language')); + uiu.el(tr, 'th', {}, ''); + const tr_input = uiu.el(display_tbody, 'tr'); + create_undecorated_input("text", uiu.el(tr_input, 'td', {}), 'normalizations_origin'); + create_undecorated_input("text", uiu.el(tr_input, 'td', {}), 'normalizations_replace'); + + // Tournament language selection + const language_td = uiu.el(tr_input, 'td'); + const language_select = uiu.el(language_td, 'select', { + name: 'language', + required: 'required', + name: 'normalizations_language', + id: 'normalizations_language', + }); + const all_langs = ci18n.get_all_languages(); + for (const l of all_langs) { + const l_attrs = { + value: l['announcements:lang'], + }; + if (l._code === curt.language) { + l_attrs.selected = 'selected'; + } + uiu.el(language_select, 'option', l_attrs, l._name); + } + + //create_undecorated_input("text", uiu.el(tr_input, 'td', {}), 'normalizations_language'); + const actions_td = uiu.el(tr_input, 'td', {}); + const add_btn = uiu.el(actions_td, 'button', {}, ci18n('tournament:edit:add')); + add_btn.addEventListener('click', function (e) { + + var new_normalization = {} + new_normalization.origin = document.getElementById('normalizations_origin').value; + new_normalization.replace = document.getElementById('normalizations_replace').value; + new_normalization.language = document.getElementById('normalizations_language').value; + + send_with_live_status({ + type: 'normalization_add', + tournament_key: curt.key, + normalization: new_normalization, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + for (const nv of curt.normalizations) { + const tr = uiu.el(display_tbody, 'tr'); + uiu.el(tr, 'td', {}, nv.origin); + uiu.el(tr, 'td', {}, nv.replace); + uiu.el(tr, 'td', {}, nv.language); + const actions_td = uiu.el(tr, 'td', {}); + const delete_btn = uiu.el(actions_td, 'button', { + 'data-normalization-id': nv._id, + }, ci18n('tournament:edit:delete')); + + delete_btn.addEventListener('click', function (e) { + const del_btn = e.target; + const normalization_id = del_btn.getAttribute('data-normalization-id'); + send_with_live_status({ + type: 'normalization_remove', + tournament_key: curt.key, + normalization_id: normalization_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + } + + function render_advertisements(main) { + uiu.el(main, 'h2', 'edit', ci18n('tournament:edit:advertisements')); + + const display_table = uiu.el(main, 'table'); + const display_tbody = uiu.el(display_table, 'tbody'); + const tr = uiu.el(display_tbody, 'tr'); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:advertisements:id')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:advertisements:url')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:advertisements:type')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:advertisements:disabled')); + uiu.el(tr, 'th', {}, ''); + const tr_input = uiu.el(display_tbody, 'tr'); + uiu.el(tr_input, 'td', {}, ''); + create_undecorated_input("text", uiu.el(tr_input, 'td', {}), 'advertisement_url'); + create_undecorated_input("text", uiu.el(tr_input, 'td', {}), 'advertisement_type'); + uiu.el(tr_input, 'td', {}, ''); + const actions_td = uiu.el(tr_input, 'td', {}); + const add_btn = uiu.el(actions_td, 'button', {}, ci18n('tournament:edit:add')); + add_btn.addEventListener('click', function (e) { + + var new_advertisement = {} + new_advertisement.id = generateGUID(); + new_advertisement.url = document.getElementById('advertisement_url').value; + new_advertisement.type = document.getElementById('advertisement_type').value; + new_advertisement.disabled = false; + send_with_live_status({ + type: 'advertisement_add', + tournament_key: curt.key, + advertisement: new_advertisement, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + for (const nv of curt.advertisements) { + const tr = uiu.el(display_tbody, 'tr'); + uiu.el(tr, 'td', {}, nv.id); + uiu.el(tr, 'td', {}, nv.url); + uiu.el(tr, 'td', {}, nv.type); + uiu.el(tr, 'td', {}, nv.disabled); + const actions_td = uiu.el(tr, 'td', {}); + const delete_btn = uiu.el(actions_td, 'button', { + 'data-advertisement-id': nv._id, + }, ci18n('tournament:edit:delete')); + + delete_btn.addEventListener('click', function (e) { + const del_btn = e.target; + const advertisement_id = del_btn.getAttribute('data-advertisement-id'); + send_with_live_status({ + type: 'advertisement_remove', + tournament_key: curt.key, + advertisement_id: advertisement_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + } + function generateGUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + const random = Math.random() * 16 | 0; + const value = char === 'x' ? random : (random & 0x3 | 0x8); + return value.toString(16); + }); + } + + function set_battery_state(battery, node) { + if (battery && battery != null) { + node.removeAttribute("class"); + let level = Math.floor(battery.level * 100); + node.innerHTML = level + '%'; + if (battery.charging) { + node.classList.add('battery-status-charging'); + + node.title = ci18n('tournament:edit:displays:battery_charging_time', { + battery_charging_time : Math.floor(battery.chargingTime / 60) + }); + } else { + node.title = ci18n('tournament:edit:displays:battery_duscharging_time', { + battery_discharging_time: Math.floor(battery.dischargingTime / 60) + }); + + if (level <= 10) { + node.classList.add('battery-status-red'); + } else if (level <= 20) { + node.classList.add('battery-status-orange'); + } else if (level <= 40) { + node.classList.add('battery-status-yellow'); + } else { + node.classList.add('battery-status-green'); + } + } + } + } + + function render_logo_preview(main) { + uiu.el(main, 'h3', 'edit', ci18n('tournament:edit:logo')); + const logo_preview_container = uiu.el(main, 'div', { + style: ( + 'position:relative;text-align:center;' + + 'height: 432px; width: 768px; font-size: 70px;' + + 'background:' + (curt.logo_background_color || '#000000') + ';' + + 'color:' + (curt.logo_foreground_color || '#aaaaaa') + ';' + ), + name: "logo_preview", + }); + if (curt.logo_id) { + uiu.el(logo_preview_container, 'img', { + style: 'height: 320px;', + src: '/h/' + encodeURIComponent(curt.key) + '/logo/' + curt.logo_id, + name: 'logo_preview_img' + }); + } + uiu.el(logo_preview_container, 'div', {}, 'Court 42'); + + const logo_form = uiu.el(main, 'form', 'logo_form'); + const logo_button_id = 'logo_upload_input'; + + const custom_label = uiu.el(logo_form, 'label', { + for: logo_button_id, + style: ( + 'display:inline-block;padding:3px 8px;cursor:pointer; border:1px solid;' + + 'background:#eeeeee;color:black;border-radius:4px;margin:10px;' + ), + }, 'Logo auswählen'); + + const filename_display = uiu.el(logo_form, 'span', { + id: 'upload_filename', + style: 'font-style: italic; color: #555;', + }, curt.logo_name ? curt.logo_name : 'Noch keine Datei ausgewählt'); + + const logo_button = uiu.el(logo_form, 'input', { + id: logo_button_id, + type: 'file', + accept: 'image/*', + style: 'display:none;', + }); + logo_button.addEventListener('change', (e) => { + _upload_logo(e); + }); + const logo_colors_container = uiu.el(logo_form, 'div', { style: 'display: block' }); + const bg_col_label = uiu.el(logo_colors_container, 'label', {}, ci18n('tournament:edit:logo:background')); + const logo_background_color_input = uiu.el(bg_col_label, 'input', { + type: 'color', + name: 'logo_background_color', + value: curt.logo_background_color || '#000000', + }); + logo_background_color_input.addEventListener('input', (e) => { + send_with_live_status({ + type: 'tournament_edit_logo', + key: curt.key, + props: { + logo_background_color: e.target.value, + }, + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + }); + const fg_col_label = uiu.el(logo_colors_container, 'label', {}, ci18n('tournament:edit:logo:foreground')); + const fg_col_input = uiu.el(fg_col_label, 'input', { + type: 'color', + name: 'logo_foreground_color', + value: curt.logo_foreground_color || '#aaaaaa', + }); + fg_col_input.addEventListener('input', (e) => { + send_with_live_status({ + type: 'tournament_edit_logo', + key: curt.key, + props: { + logo_foreground_color: e.target.value, + }, + }, function (err) { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function update_logo() { + switch (get_admin_subpage()){ + case 'edit': + const logo_preview_container = document.querySelector('[name="logo_preview"]'); + logo_preview_container.style.background = curt.logo_background_color; + logo_preview_container.style.color = curt.logo_foreground_color; + let logo_background_color_input = document.querySelector('[name="logo_background_color"]'); + logo_background_color_input.value = curt.logo_background_color; + let fg_col_input = document.querySelector('[name="logo_foreground_color"]'); + fg_col_input.value = curt.logo_foreground_color; + const logo_preview_img = logo_preview_container.querySelector('[name="logo_preview_img"]'); + logo_preview_img.setAttribute('src', '/h/' + encodeURIComponent(curt.key) + '/logo/' + curt.logo_id); + const filename_display = document.querySelector('#upload_filename'); + filename_display.textContent = curt.logo_name ? curt.logo_name : 'Noch keine Datei ausgewählt'; + break; + default: + break; + } + return; + } + + function render_general_displaysettings(main) { + let used_configs = new Set(); + curt.displays.forEach((d) => { + used_configs.add(d.displaysetting_id); + }); + + uiu.el(main, 'h3', 'edit', ci18n('tournament:edit:general_displaysettings')); + const display_settings_table = uiu.el(main, 'table'); + const display_settings_tbody = uiu.el(display_settings_table, 'tbody'); + const tr = uiu.el(display_settings_tbody, 'tr'); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:setting')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:description')); + uiu.el(tr, 'th', {}, ""); + + for (const s of curt.displaysettings) { + const tr = uiu.el(display_settings_tbody, 'tr', { 'data-displaysetting_id': s.id }); + render_general_displaysetting_line(tr, s, used_configs); + } + } + + function render_general_displaysetting_line(parrent, s, used_configs) { + uiu.el(parrent, 'th', {}, s.description ||s.id); + const description_td = uiu.el(parrent, 'td', {}, s.devicemode + (s.devicemode == 'display' ? ' (' + s.displaymode_style + ')' : '')); + const actions_td = uiu.el(parrent, 'td', {}); + const edit_btn = uiu.el(actions_td, 'button', { + 'data-display_setting_id': s.id, + }, 'Edit'); + + edit_btn.addEventListener('click', (e) => { + on_edit_display_setting_button_click(e); + }); + + + const delete_btn = uiu.el(actions_td, 'button', { + 'data-display-setting-id': s.id, + }, 'Delete'); + + if (used_configs.has(s.id)) { + delete_btn.setAttribute('disabled', 'disabled'); + } + + delete_btn.addEventListener('click', (e) => { + const del_btn = e.target; + const setting_id = del_btn.getAttribute('data-display-setting-id'); + + send_with_live_status({ + type: 'delete_display_setting', + tournament_key: curt.key, + setting_id: setting_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function _cancel_ui_edit_display_setting() { + const dlg = document.querySelector('.display_setting_edit_dialog'); + if (!dlg) { + return; // Already cancelled + } + cbts_utils.esc_stack_pop(); + uiu.remove(dlg); + + ui_edit(); + } + + function on_edit_display_setting_button_click(e) { + const btn = e.target; + const display_setting_id = btn.getAttribute('data-display_setting_id'); + ui_edit_display_setting(display_setting_id); + } + + function ui_edit_display_setting(display_setting_id) { + const display_setting = structuredClone(utils.find(curt.displaysettings, d => d.id === display_setting_id)); + crouting.set('t/' + curt.key + '/edit/s/' + display_setting_id, {}, _cancel_ui_edit_display_setting); + + cbts_utils.esc_stack_push(_cancel_ui_edit_display_setting); + + const body = uiu.qs('body'); + const dialog_bg = uiu.el(body, 'div', 'dialog_bg display_setting_edit_dialog', { + 'data-display_setting_id': display_setting_id, + }); + const dialog = uiu.el(dialog_bg, 'div', 'dialog'); + + uiu.el(dialog, 'h3', {}, ci18n('Edit display setting')); + + const form = uiu.el(dialog, 'form'); + uiu.el(form, 'input', { + type: 'hidden', + name: 'display_setting_id', + value: display_setting_id, + }); + render_edit_display_setting(form, display_setting); + + const buttons = uiu.el(form, 'div', { + style: 'margin-top: 2em;', + }); + + const btn = uiu.el(buttons, 'button', { + 'class': 'match_save_button', + role: 'submit', + }, ci18n('Change')); + + form_utils.onsubmit(form, function(d) { + const displaysetting = create_displaysettings_object(d); + + send_with_live_status({ + type: 'edit_display_setting', + tournament_key: curt.key, + displaysetting: displaysetting, + }, err => { + if (err) { + return cerror.net(err); + } + _cancel_ui_edit_display_setting(); + }); + }); + + const cancel_btn = uiu.el(buttons, 'span', 'match_cancel_link vlink', ci18n('Cancel')); + cancel_btn.addEventListener('click', _cancel_ui_edit_display_setting); + } + crouting.register(/t\/([a-z0-9]+)\/edit\/s\/([-a-zA-Z0-9_ ]+)$/, function(m) { + ctournament.switch_tournament(m[1], function() { + ui_edit_display_setting(m[2]); + }); + }, change.default_handler(() => { + const dlg = uiu.qs('.display_setting_edit_dialog'); + const display_setting_id = dlg.getAttribute('data-display_setting_id'); + ui_edit_display_setting(display_setting_id); + })); + + function render_edit_display_setting(form, display_setting) { + const edit_display_setting_container = uiu.el(form, 'div', 'edit_display_setting_container'); + const id_div = uiu.el(edit_display_setting_container, 'div'); + uiu.el(id_div, 'span', 'display_setting_id', ci18n('display_setting:id')); + uiu.el(id_div, 'input', { + type: 'text', + name: 'display_setting_id', + size: 24, + required: 'required', + value: display_setting.id || '', + tabindex: 1, + disabled: 'disabled', + }); + + + const description_div = uiu.el(edit_display_setting_container, 'div'); + uiu.el(description_div, 'span', 'display_setting_description', 'Description:'); + uiu.el(description_div, 'input', { + type: 'text', + name: 'display_setting_description', + placeholder: ci18n('e.g. MX O55'), + size: 18, + value: display_setting.description || '', + tabindex: 2, + }); + + const ALL_DEVICE_MODES = [ + 'umpire', + 'display' + ]; + + + const calculated_style = (display_setting.devicemode === 'umpire' ? 'umpire' : display_setting.displaymode_style); + + + render_drop_down(edit_display_setting_container, ci18n('display_setting:devicemode'), 'devicemode', true, ALL_DEVICE_MODES, display_setting.devicemode || ''); + const displaystyle_select = render_drop_down(edit_display_setting_container, ci18n('display_setting:style'), 'displaymode_style', (display_setting.devicemode === 'umpire' ? 'umpire' : true), displaymode.ALL_STYLES, display_setting.displaymode_style || ''); + + displaystyle_select.addEventListener('change', (e) => { + const style = e.target; + update_edit_display_setting(style.value); + }); + + render_check_box(edit_display_setting_container, ci18n('display_setting:show_pause'), 'show_pause', calculated_style, display_setting.d_show_pause); + render_check_box(edit_display_setting_container, ci18n('display_setting:show_court_number'), 'show_court_number', calculated_style, display_setting.d_show_court_number); + render_check_box(edit_display_setting_container, ci18n('display_setting:show_competition'), 'show_competition', calculated_style, display_setting.d_show_competition); + render_check_box(edit_display_setting_container, ci18n('display_setting:show_round'), 'show_round', calculated_style, display_setting.d_show_round); + render_check_box(edit_display_setting_container, ci18n('display_setting:show_middle_name'), 'show_middle_name', calculated_style, display_setting.d_show_middle_name); + render_check_box(edit_display_setting_container, ci18n('display_setting:show_doubles_receiving'), 'show_doubles_receiving', calculated_style, display_setting.d_show_doubles_receiving); + + const select_color_div = uiu.el(edit_display_setting_container, 'div', { style: 'display: block' }); + const select_color_label = uiu.el(select_color_div, 'label', {}, ci18n('display_setting:colors')); + render_select_color(select_color_label, 'c0', calculated_style, display_setting.d_c0); + render_select_color(select_color_label, 'c1', calculated_style, display_setting.d_c1); + render_select_color(select_color_label, 'cb0', calculated_style, display_setting.d_cb0); + render_select_color(select_color_label, 'cb1', calculated_style, display_setting.d_cb1); + render_select_color(select_color_label, 'cbg', calculated_style, display_setting.d_cbg); + render_select_color(select_color_label, 'cbg2', calculated_style, display_setting.d_cbg2); + render_select_color(select_color_label, 'cbg3', calculated_style, display_setting.d_cbg3); + render_select_color(select_color_label, 'cbg4', calculated_style, display_setting.d_cbg4); + render_select_color(select_color_label, 'cfg', calculated_style, display_setting.d_cfg); + render_select_color(select_color_label, 'cfg2', calculated_style, display_setting.d_cfg2); + render_select_color(select_color_label, 'cfg3', calculated_style, display_setting.d_cfg3); + render_select_color(select_color_label, 'cfg4', calculated_style, display_setting.d_cfg4); + render_select_color(select_color_label, 'cfgdark', calculated_style, display_setting.d_cfgdark); + render_select_color(select_color_label, 'cexp', calculated_style, display_setting.d_cexp); + render_select_color(select_color_label, 'ct', calculated_style, display_setting.d_ct); + render_select_color(select_color_label, 'cborder', calculated_style, display_setting.d_cborder); + render_select_color(select_color_label, 'cserv', calculated_style, display_setting.d_cserv); + render_select_color(select_color_label, 'cserv2', calculated_style, display_setting.d_cserv2); + render_select_color(select_color_label, 'crecv', calculated_style, display_setting.d_crecv); + render_select_color(select_color_label, 'ctim_blue', calculated_style, display_setting.d_ctim_blue); + render_select_color(select_color_label, 'ctim_active', calculated_style, display_setting.d_ctim_active); + render_check_box(edit_display_setting_container, ci18n('display_setting:use_team_colors'), 'team_colors', calculated_style, display_setting.d_team_colors); + render_select_number(edit_display_setting_container, ci18n('display_setting:scale'), 'scale', calculated_style, display_setting.d_scale, 20, 500); + + const ALL_BUP_LANGUAGES = [ + ci18n('display_setting:language_automatic'), + ci18n('display_setting:language_en'), + ci18n('display_setting:language_de'), + ci18n('display_setting:language_de-AT'), + ci18n('display_setting:language_de-CH'), + ci18n('display_setting:language_fr-CH'), + ci18n('display_setting:language_nl-BE'), + ] + + const SHORT_BUP_LANGUAGES = [ + 'auto', + 'en', + 'de', + 'de-AT', + 'de-CH', + 'fr-CH', + 'nl-BE' + ] + + // let current_language = ''; + + // for (const [i, value] of SHORT_BUP_LANGUAGES.entries()) { + // if ((display_setting.language || '') == value) { + // current_language = ALL_BUP_LANGUAGES[i]; + // break; + // } + // } + + render_drop_down(edit_display_setting_container, ci18n('display_setting:language'), 'language', true, SHORT_BUP_LANGUAGES, display_setting.language, ALL_BUP_LANGUAGES); + + + const ALL_ASK_FULLSCREAN_MODES = [ + 'always', + 'auto', + 'never', + ]; + render_drop_down(edit_display_setting_container, ci18n('display_setting:fullscreen_ask'), 'fullscreen_ask', true, ALL_ASK_FULLSCREAN_MODES, display_setting.fullscreen_ask || ''); + + + const ALL_ANNOUNCEMENT_MODES = [ + 'none', + 'all', + 'except-first', + ]; + render_drop_down(edit_display_setting_container, ci18n('display_setting:show_announcements'), 'show_announcements', calculated_style, ALL_ANNOUNCEMENT_MODES, display_setting.show_announcements || ''); + + render_select_number(edit_display_setting_container, ci18n('display_setting:button_block_timeout'), 'button_block_timeout', calculated_style, display_setting.button_block_timeout, 0, 5000); + + render_check_box(edit_display_setting_container, ci18n('display_setting:negative_timers'), 'negative_timers', calculated_style, display_setting.negative_timers); + render_check_box(edit_display_setting_container, ci18n('display_setting:shuttle_counter'), 'shuttle_counter', calculated_style, display_setting.shuttle_counter); + render_check_box(edit_display_setting_container, ci18n('display_setting:editmode_doubleclick'), 'editmode_doubleclick', calculated_style, display_setting.editmode_doubleclick); + + const ALL_CLICK_MODES = [ + 'auto', + 'click', + 'touchstart', + 'touchend', + ]; + render_drop_down(edit_display_setting_container, ci18n('display_setting:click_mode'), 'click_mode', calculated_style, ALL_CLICK_MODES, display_setting.click_mode || ''); + + const ALL_STYLE_MODES = [ + 'default', + 'complete', + 'clean', + 'focus', + 'hidden', + ]; + + render_drop_down(edit_display_setting_container, ci18n('display_setting:settings_style'), 'style', calculated_style, ALL_STYLE_MODES, display_setting.style || ''); + render_select_number(edit_display_setting_container, ci18n('display_setting:network_timeout'), 'network_timeout', true, display_setting.network_timeout, 1, 600000); + render_select_number(edit_display_setting_container, ci18n('display_setting:network_update_interval'), 'network_update_interval', true, display_setting.network_update_interval, 1, 600000); + } + + function render_drop_down(container, label_text, select_name, displaystyle, values, curval, labels) { + if(!labels) { + labels = values; + } + + const div = uiu.el(container, 'div', {field_name: select_name}); + uiu.el(div, 'span', 'label', label_text); + const select = uiu.el(div, 'select', { + name: select_name, + size: 1, + }); + uiu.empty(select); + for (const [i, s] of values.entries()) { + const attrs = { + value: s, + label: labels[i] || s, + }; + if (s === curval) { + attrs.selected = 'selected'; + } + uiu.el(select, 'option', attrs, s); + } + + uiu.visible(div, (displaystyle === true || displaymode.option_applies(displaystyle, select_name))); + + return select; + } + + function render_check_box(container, label_text, checkbox_name, displaystyle, is_checked) { + const div = uiu.el(container, 'div', {field_name: checkbox_name}); + const label = uiu.el(div, 'label'); + const attrs = { + type: 'checkbox', + name: checkbox_name, + }; + + if (is_checked) { + attrs.checked = 'checked'; + } + + uiu.el(label, 'input', attrs); + uiu.el(label, 'span', 'display_setting_label', label_text); + + uiu.visible(div, (displaystyle === true || displaymode.option_applies(displaystyle, checkbox_name))); + } + + function render_select_color(container, field_name, displaystyle, value) { + const input = uiu.el(container, 'input', { + type: 'color', + name: field_name, + title: field_name, + field_name: field_name, + value: value || '#000000', + }); + + uiu.visible(input, (displaystyle === true ||displaymode.option_applies(displaystyle, field_name))); + } + + function render_select_number(container, label_text, input_name, displaystyle, value, min_value, max_value) { + const div = uiu.el(container, 'div', {field_name: input_name}); + const label = uiu.el(div, 'span', 'label', label_text); + uiu.el(div, 'input', { + type: 'number', + name: input_name, + min: min_value || 0, + max: max_value || 0, + value: value || 0, + }); + + uiu.visible(div, (displaystyle === true ||displaymode.option_applies(displaystyle, input_name))); + } + + function create_displaysettings_object(d) { + const displaysetting = { + id: d.display_setting_id, + description: d.display_setting_description || '', + devicemode: d.devicemode || 'display', + displaymode_style: d.displaymode_style || 'tournamentcourt', + d_show_pause: d.show_pause == 'on' ? true : false, + d_show_court_number: d.show_court_number == 'on' ? true : false, + d_show_competition: d.show_competition == 'on' ? true : false, + d_show_round: d.show_round == 'on' ? true : false, + d_show_middle_name: d.show_middle_name == 'on' ? true : false, + d_show_doubles_receiving: d.show_doubles_receiving == 'on' ? true : false, + d_c0: d.c0 || '#50e87d', + d_c1: d.c1 || '#f76a23', + d_cb0: d.cb0 || '#000000', + d_cb1: d.cb1 || '#000000', + d_cbg: d.cbg || '#000000', + d_cbg2: d.cbg2 || '#d9d9d9', + d_cbg3: d.cbg3 || '#252525', + d_cbg4: d.cbg4 || '#404040', + d_cfg: d.cfg || '#ffffff', + d_cfg2: d.cfg2 || '#aaaaaa', + d_cfg3: d.cfg3 || '#cccccc', + d_cfg4: d.cfg4 || '#000000', + d_cfgdark: d.cfgdark || '#000000', + d_cexp: d.cexp || '#000000', + d_ct: d.ct || '#80ff00', + d_cborder: d.cborder || '#444444', + d_cserv: d.cserv || '#fff200', + d_cserv2: d.cserv2 || '#dba766', + d_crecv: d.crecv || '#707676', + d_ctim_blue: d.ctim_blue || '#0070c0', + d_ctim_active: d.ctim_active || '#ffc000', + d_team_colors: d.team_colors == 'on' ? true : false, + d_scale: d.scale || '100', + fullscreen_ask: d.fullscreen_ask || 'auto', + show_announcements: d.show_announcements || 'all', + button_block_timeout: d.button_block_timeout || '100', + negative_timers: d.negative_timers == 'on' ? true : false, + shuttle_counter: d.shuttle_counter == 'on' ? true : false, + editmode_doubleclick: d.editmode_doubleclick == 'on' ? true : false, + click_mode: d.click_mode || 'auto', + style: d.style || 'complete', + network_timeout: d.network_timeout || '10000', + network_update_interval: d.network_update_interval || '10000', + language: d.language || 'auto', + } + + // + + return displaysetting; + } + + function update_edit_display_setting(displaystyle) + { + const names = [ 'show_pause', 'show_court_number', 'show_competition', 'show_round', 'show_middle_name', 'show_doubles_receiving', + 'c0', 'c1', 'cb0', 'cb1', 'cbg', 'cbg2', 'cbg3', 'cbg4', 'cfg', 'cfg2', 'cfg3', 'cfg4', 'cfgdark', 'cexp', 'ct', + 'cborder', 'cserv', 'cserv2', 'crecv', 'ctim_blue', 'ctim_active', 'team_colors', 'scale', + 'show_announcements', 'button_block_timeout', 'negative_timers', 'shuttle_counter', 'editmode_doubleclick', + 'click_mode', 'style', 'language']; + + names.forEach((field_name) => { + const update = uiu.qs('[field_name='+field_name+']'); + uiu.visible(update, (displaystyle === true || displaymode.option_applies(displaystyle, field_name))); + }); + } + + function update_general_displaysettings(c) + { + //const general_displaysettings_div = uiu.qs('.general_displaysettings'); + const general_displaysettings_div = document.querySelector(".general_displaysettings"); + if(general_displaysettings_div) { + general_displaysettings_div.innerHTML = ''; + render_general_displaysettings(general_displaysettings_div); + } + } + + function render_displaysettings(general_displaysettings_div) { + uiu.el(general_displaysettings_div, 'h3', 'edit', ci18n('tournament:edit:displays')); + + const display_table = uiu.el(general_displaysettings_div, 'table'); + const display_tbody = uiu.el(display_table, 'tbody', 'display_tbody'); + const tr = uiu.el(display_tbody, 'tr'); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:num')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:hostname')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:batterylevel')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:court')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:setting')); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:displays:onlinestatus')); + uiu.el(tr, 'th', {}, ""); + uiu.el(tr, 'th', {}, ""); + + + for (const display of curt.displays) { + const tr = uiu.el(display_tbody, 'tr', { 'data-display_id': display.client_id }); + render_display(tr, display); + } + } + + function update_display(display) { + // Do this function only if the Display view (in on edit) is open + if(!document.querySelectorAll('.display_tbody').length) { + return; + } + + var nodes = document.querySelectorAll('[data-display_id=' + JSON.stringify(display.client_id) + ']'); + if(nodes.length > 0) { + uiu.qsEach('[data-display_id=' + JSON.stringify(display.client_id) + ']', function (display_tr) { + display_tr.innerHTML = ''; + render_display(display_tr, display); + }); + } + else { + new_display(display); + } + } + + function new_display(display) { + const display_tbody = document.querySelector(".display_tbody"); + const tr = uiu.el(display_tbody, 'tr', { 'data-display_id': display.client_id }); + render_display(tr, display); + + for (const child of display_tbody.children) { + const child_id = child.dataset.display_id; + if(child_id && Number(child_id) > Number(display.client_id)) + { + display_tbody.insertBefore(tr, child); + } + } + } + + + function render_display(tr, display) { + tr.setAttribute('class', (!display.online) ? 'offline' : (display.wait_for_done ? 'wait_for_done' : 'online')); + uiu.el(tr, 'th', {}, display.client_id); + uiu.el(tr, 'th', {}, display.hostname); + var battery_node = uiu.el(tr, 'td', {}, 'N/A'); + set_battery_state(display.battery, battery_node); + createCourtSelectBox(uiu.el(tr, 'td', {}, ''), display.client_id, display.court_id); + createDisplaySettingsSelectBox(uiu.el(tr, 'td', {}, ''), display.client_id, display.displaysetting_id); + uiu.el(tr, 'td', {}, (!display.online) ? 'offline' : 'online'); + const actions_td = uiu.el(tr, 'td', {}); + const reset_btn = uiu.el(actions_td, 'button', { + 'data-display-client-id': display.client_id, + }, 'Restart'); + + if (!display.online) { + reset_btn.setAttribute('disabled', 'disabled'); + } + reset_btn.addEventListener('click', function (e) { + const rst_btn = e.target; + const display_client_id = rst_btn.getAttribute('data-display-client-id'); + send_with_live_status({ + type: 'display_reset', + tournament_key: curt.key, + display_client_id: display_client_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + + const delete_td = uiu.el(tr, 'td', {}); + const delete_btn = uiu.el(delete_td, 'button', { + 'data-display-client-id': display.client_id, + }, 'Delete'); + if (display.online) { + delete_btn.setAttribute('disabled', 'disabled'); + } + delete_btn.addEventListener('click', function (e) { + const del_btn = e.target; + const display_client_id = del_btn.getAttribute('data-display-client-id'); + send_with_live_status({ + type: 'display_delete', + tournament_key: curt.key, + display_client_id: display_client_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function delete_display(c) { + uiu.qsEach('[data-display_id=' + JSON.stringify(c.val) + ']', function (display_tr) { + display_tr.parentNode.removeChild(display_tr); + }); + } + + function render_locations(main) { + const location_div = uiu.el(main, 'div', 'locations_div'); + uiu.el(location_div, 'h2', 'edit', ci18n('tournament:edit:location')); + + const locations_table = uiu.el(location_div, 'table', 'locations_table'); + const locations_tbody = uiu.el(locations_table, 'tbody'); + + const tr = uiu.el(locations_tbody, 'tr'); + uiu.el(tr, 'th', {}, ci18n('tournament:edit:location')); + uiu.el(tr, 'th', {}, 'In Vorbereitungd Ergänzung'); + uiu.el(tr, 'th', {}, 'Meetingpoint durchsage'); + uiu.el(tr, 'th', {}, 'In Vorbereitung Icon'); + uiu.el(tr, 'th', {}, ''); + + let highlight_in_use = []; + for (const l of curt.locations) { + if(l.highlight) { + highlight_in_use.push(l.highlight); + } + } + + for (const l of curt.locations) { + const tr = uiu.el(locations_tbody, 'tr'); + const name_th = uiu.el(tr, 'th', {}); + uiu.el(name_th, 'div', {}, l.name); + + const content = [1, 2, 3, 4, 5, 6]; + const selected = l.highlight; + const select_color = uiu.el(name_th, 'select', {id: 'select_highlight', class: 'highlight_' + selected, 'data-location_id': l._id}); + for (const item of content) { + const attrs = { + 'data-display-setting-id': item, + value: item, + class: 'highlight_' + item, + style: (highlight_in_use.includes(item) && selected !== item) ? 'display:none;' : '', + } + if ((selected === item)) { + attrs.selected = 'selected'; + } + uiu.el(select_color, 'option', attrs); + //} + } + + select_color.addEventListener('change', (e) => { + e.target.classList = [e.target.value]; + send_location_to_admin(e.target.parentNode.parentNode, e.target.getAttribute('data-location_id')); + }); + + const preparation_td = uiu.el(tr, 'td', {}); + const preparation_input = create_textarea_input("textarea", preparation_td, 'preparation_addition'); + preparation_input.value = l.preparation_addition; + preparation_input.setAttribute('data-location-id', l._id); + preparation_input.setAttribute('maxlength', 175); + preparation_input.addEventListener('focusout', (e) => { + send_location_to_admin(e.target.parentNode.parentNode, e.target.getAttribute('data-location-id')); + }); + const meetinpoint_td = uiu.el(tr, 'td', {}); + const meetingpoint_input = create_textarea_input("textarea", meetinpoint_td, 'meetingpoint_announcement'); + meetingpoint_input.value = l.meetingpoint_announcement; + meetingpoint_input.setAttribute('data-location-id', l._id); + meetingpoint_input.setAttribute('maxlength', 175); + meetingpoint_input.addEventListener('focusout', (e) => { + send_location_to_admin(e.target.parentNode.parentNode, e.target.getAttribute('data-location-id')); + }); + const icon_td = uiu.el(tr, 'td', 'icon_td'); + uiu.el(icon_td, 'img', { + style: 'height: 40px;', + src: l.logo_id ? '/h/' + encodeURIComponent(curt.key) + '/logo/' + l.logo_id : '/static/icons/preparation.svg', + name: 'location_logo_img', + 'data-location_id': l._id + }); + + const logo_form = uiu.el(icon_td, 'form', 'logo_form'); + const logo_button_id = l._id +'_logo_upload_input'; + + const filename_display = uiu.el(logo_form, 'div', { + class: 'upload_filename_location', + 'data-location_id': l._id, + }, l.logo_name ? l.logo_name : 'preparation.svg'); + + const custom_label = uiu.el(logo_form, 'label', { + for: logo_button_id, + style: ( + 'display:inline-block;padding:3px 8px;cursor:pointer; border:1px solid;' + + 'background:#eeeeee;color:black;border-radius:4px;margin:5px;font-size:small;' + ), + }, 'ändern'); + + const logo_button = uiu.el(logo_form, 'input', { + id: logo_button_id, + type: 'file', + accept: 'image/*', + style: 'display:none;', + 'data-location_id': l._id, + }); + logo_button.addEventListener('change', (e) => { + _upload_location_logo(e); + }); + + const actions_td = uiu.el(tr, 'td', {}); + const del_btn = uiu.el(actions_td, 'button', { + 'data-location-id': l._id, + }, 'Delete'); + del_btn.addEventListener('click', function (e) { + const del_btn = e.target; + const location_id = del_btn.getAttribute('data-location-id'); + if (confirm('Do you really want to delete ' + location_id + '? (Will not do anything yet!)')) { + debug.log('TODO: would now delete court'); + } + }); + } + } + + function _upload_location_logo(e) { + const input = e.target; + const location_id = e.target.getAttribute('data-location_id'); + if (!input.files.length) return; + + const reader = new FileReader(); + reader.readAsDataURL(input.files[0]); + reader.onload = () => { + send_with_live_status({ + type: 'tournament_upload_location_logo', + tournament_key: curt.key, + data_url: reader.result, + name: e.target.files[0].name, + location_id + }, (err) => { + if (err) { + return cerror.net(err); + }` + input.closest('form').reset();` + }); + }; + reader.onerror = (e) => { + alert('Failed to upload: ' + e); + }; + } + + function update_location_logo(location_id, logo_id, logo_name) { + switch (get_admin_subpage()){ + case 'edit': + const location_logo_img = document.querySelector(`[name="location_logo_img"][data-location_id="${location_id}"]`); + location_logo_img.setAttribute('src', '/h/' + encodeURIComponent(curt.key) + '/logo/' + logo_id); + const filename_display = document.querySelector(`.upload_filename_location[data-location_id="${location_id}"]`); + filename_display.textContent = logo_name; + break; + default: + break; + } + return; + } + + function send_location_to_admin(parent, location_id) { + const highlight = parseInt(parent.querySelector("#select_highlight").value, 10); + const preparation_addition = parent.querySelector("#preparation_addition").value; + const meetingpoint_announcement = parent.querySelector("#meetingpoint_announcement").value; + + send_with_live_status({ + type: 'location_changed', + tournament_key: curt.key, + location_id, + highlight: highlight, + preparation_addition, + meetingpoint_announcement, + }, function (err, response) { + if (err) { + return cerror.net(err); + } + }); } - crouting.register(rex, function(m) { - switch_tournament(m[1], func); - }, handler); + function update_location(location_id, highlight, preparation_addition, meetingpoint_announcement) { + switch (get_admin_subpage()){ + case 'edit': + const locations_table = document.querySelector('.locations_table'); + const location_div = locations_table.parentElement; + location_div.innerHTML=""; + render_locations(location_div); + + break; + default: + break; + } + return; + }; + +/* ============================================================ + * DROP-ZONES (schmale Reihen zum Droppen) + * ============================================================ */ + +function add_drop_zones_to_tbody(tbody, { + row_selector = '.officials_row', + zone_class = 'drop-zone', + zone_active_class = 'drop-zones-active', + is_header_row = (row) => row.classList.contains('officials_list_header'), + col_count = 3, + on_zone_dragover = (tbody, insertBeforeRow, e) => {}, +} = {}) { + for (const z of [...tbody.querySelectorAll(`.${zone_class}`)]) z.remove(); + tbody.classList.add(zone_active_class); + + const rows = [...tbody.querySelectorAll(row_selector)]; + const header = rows.find(is_header_row) || null; + + const dataRows = rows.filter(row => + row !== header && + row.getAttribute('data-official-id') && + !row.classList.contains(zone_class) + ); + + function makeZone(insertBeforeRow) { + const zone = document.createElement('div'); + zone.className = `officials_drop_zone ${zone_class}`; + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + on_zone_dragover(tbody, insertBeforeRow, e); + }); + zone.addEventListener('drop', (e) => e.preventDefault()); + zone.addEventListener('dragenter', () => zone.classList.add('drop-zone-hover')); + zone.addEventListener('dragleave', () => zone.classList.remove('drop-zone-hover')); + return zone; + } + + const topZone = makeZone(dataRows[0] || null); + if (header) { + if (header.nextSibling) tbody.insertBefore(topZone, header.nextSibling); + else tbody.appendChild(topZone); + } else { + tbody.insertBefore(topZone, tbody.firstChild); + } + + for (let i = 0; i < dataRows.length; i++) { + const current = dataRows[i]; + const next = dataRows[i + 1] || null; + const zone = makeZone(next); + if (current.nextSibling) tbody.insertBefore(zone, current.nextSibling); + else tbody.appendChild(zone); + } +} + + +function remove_drop_zones_from_tbody(tbody, { + zone_class = 'drop-zone', + zone_active_class = 'drop-zones-active', +} = {}) { + tbody.classList.remove(zone_active_class); + for (const z of [...tbody.querySelectorAll(`.${zone_class}`)]) { + z.remove(); + } +} + +/* ============================================================ + * MULTI-TABLE DND (mit Drop-Zones, zwischen Tabellen) + * ============================================================ */ + +function enable_multitable_row_dragdrop(tbodies, { + row_selector = '.officials_row', + table_id_attr = 'data-table-id', + row_id_attr = 'data-official-id', + is_header_row = (row) => row.classList.contains('officials_list_header'), + can_drag_row = (row) => !is_header_row(row) && !row.classList.contains('drop-zone'), + col_count = 3, + on_move = ({ row_id, from_table, to_table, from_order, to_order }) => {}, +} = {}) { + let dragged_tr = null; + let from_tbody = null; + + function set_dragging(tr, isDragging) { + if (!tr) return; + tr.classList.toggle('dragging', !!isDragging); + } + + function get_table_id(tbody) { + return tbody?.getAttribute(table_id_attr) || ''; + } + + function get_order_ids(tbody) { + if (!tbody) return []; + return [...tbody.querySelectorAll(row_selector)] + .filter(row => !is_header_row(row) && !row.classList.contains('drop-zone')) + .map(row => row.getAttribute(row_id_attr)) + .filter(Boolean); + } + + function insert_dragged_into_tbody(tbody, insertBeforeRow) { + if (!dragged_tr) return; + + if (insertBeforeRow == null) { + tbody.appendChild(dragged_tr); + } else { + tbody.insertBefore(dragged_tr, insertBeforeRow); + } + } + + // Drop-Zones beim Start aktivieren + function activate_drop_zones() { + for (const tbody of tbodies) { + add_drop_zones_to_tbody(tbody, { + row_selector, + is_header_row, + col_count, + on_zone_dragover: (target_tbody, insertBeforeRow, e) => { + if (!dragged_tr) return; + insert_dragged_into_tbody(target_tbody, insertBeforeRow); + } + }); + } + } + + function deactivate_drop_zones() { + for (const tbody of tbodies) { + remove_drop_zones_from_tbody(tbody); + } + } + + // Rows draggable machen + for (const tbody of tbodies) { + for (const tr of tbody.querySelectorAll(row_selector)) { + if (!can_drag_row(tr)) continue; + + tr.draggable = true; + + tr.addEventListener('dragstart', (e) => { + dragged_tr = tr; + from_tbody = tr.closest('tbody'); + set_dragging(tr, true); + + // Drop-Zones global aktivieren + activate_drop_zones(); + + // Firefox benötigt Daten im dataTransfer + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', tr.getAttribute(row_id_attr) || ''); + } + }); + + tr.addEventListener('dragend', () => { + if (!dragged_tr) return; + + set_dragging(dragged_tr, false); + + // Drop-Zones entfernen + deactivate_drop_zones(); + + const row_id = dragged_tr.getAttribute(row_id_attr) || ''; + const to_tbody = dragged_tr.closest('tbody'); + + const from_table = get_table_id(from_tbody); + const to_table = get_table_id(to_tbody); + + const from_order = get_order_ids(from_tbody); + const to_order = get_order_ids(to_tbody); + + if ( + skip_next_official_list_move && + skip_next_official_list_move.row_id === row_id && + skip_next_official_list_move.from_table === from_table + ) { + skip_next_official_list_move = null; + dragged_tr = null; + from_tbody = null; + return; + } + + dragged_tr = null; + from_tbody = null; + + on_move({ row_id, from_table, to_table, from_order, to_order }); + }); + } + } + +for (const tbody of tbodies) { + tbody.addEventListener('dragover', (e) => { + if (!dragged_tr) return; + e.preventDefault(); + + // 1) Wenn wir über dem Spacer sind: vor Spacer einfügen (Preview korrekt) + const header_tr = e.target.closest ? e.target.closest('.officials_row') : null; + const isHeader = header_tr && is_header_row(header_tr); + if (isHeader) { + const first_data = tbody.querySelector(`${row_selector}[${row_id_attr}]:not(.drop-zone)`); + if (first_data) { + tbody.insertBefore(dragged_tr, first_data); + } else { + tbody.appendChild(dragged_tr); + } + return; + } + + const data_tr = e.target.closest ? e.target.closest(`${row_selector}[${row_id_attr}]`) : null; + if (data_tr && data_tr !== dragged_tr) { + const box = data_tr.getBoundingClientRect(); + const before = e.clientY < (box.top + box.height / 2); + if (before) tbody.insertBefore(dragged_tr, data_tr); + else tbody.insertBefore(dragged_tr, data_tr.nextSibling); + return; + } + + // sonst nichts tun: Drop-Zones übernehmen die Präzision + }); + + tbody.addEventListener('drop', (e) => { + if (!dragged_tr) return; + e.preventDefault(); + }); +} +} + + +/* ============================================================ + * DEINE TABELLE (pro Feld) - gibt TBODY zurück + * ============================================================ */ + +function create_official_role_checkbox(host, official, field) { + const wrap = uiu.el(host, 'div', 'officials_role_toggle'); + const cb = create_simple_checkbox( + wrap, + { name: `${field}_cb`, 'data-official-id': official._id || '' }, + !!official[field] + ); + if (!official._id) { + cb.disabled = true; + return cb; + } + cb.addEventListener('change', (e) => { + send_with_live_status({ + type: 'official_edit', + tournament_key: official.tournament_key, + official_id: official._id, + field, + value: e.target.checked + }, err => { if (err) return cerror.net(err); }); + }); + return cb; +} + +function render_officials_table(main, { + title = null, + rows, + table_id, + name_header = 'Name', + first_cell_render = null, + leading_header = null, + leading_cell_render = null +}) { + if (title) { + uiu.el(main, 'h2', 'edit', title); + } + + const list = uiu.el(main, 'div', 'officials_list'); + list.setAttribute('data-table-id', table_id); + + const header = uiu.el(list, 'div', 'officials_row officials_list_header'); + if (leading_header !== null) { + uiu.el(header, 'div', 'officials_cell officials_cell_leading', leading_header); + } + uiu.el(header, 'div', 'officials_cell officials_cell_name', name_header); + const umpireHead = uiu.el(header, 'div', 'officials_cell officials_cell_role'); + uiu.el(umpireHead, 'div', { class: 'umpire' }); + const serviceHead = uiu.el(header, 'div', 'officials_cell officials_cell_role'); + uiu.el(serviceHead, 'div', { class: 'service_judge' }); + + rows.forEach((o) => { + const row = uiu.el(list, 'div', 'officials_row', { 'data-official-id': o._id || '' }); + + if (leading_cell_render) { + const leading = uiu.el(row, 'div', 'officials_cell officials_cell_leading'); + leading_cell_render(leading, o); + } + + const nameCell = uiu.el(row, 'div', 'officials_cell officials_cell_name'); + if (first_cell_render) { + first_cell_render(nameCell, o); + } else { + uiu.text(nameCell, o.name || `${o.firstname} ${o.surname}`.trim()); + } + + create_official_role_checkbox(uiu.el(row, 'div', 'officials_cell officials_cell_role'), o, 'is_umpire'); + create_official_role_checkbox(uiu.el(row, 'div', 'officials_cell officials_cell_role'), o, 'is_service_judge'); + }); + + return { table: list, tbody: list }; +} + +function render_officials_by_timestamp(main, { + title = null, + officials, + timestamp_field, + min_height_px = 240, + name_header = 'Name', + first_cell_render = null, + leading_header = null, + leading_cell_render = null +}) { + const rows = officials + .filter(o => o[timestamp_field] !== null) + .sort((a, b) => a[timestamp_field] - b[timestamp_field]); + + return render_officials_table(main, { + title, + rows, + table_id: timestamp_field, + min_height_px, + name_header, + first_cell_render, + leading_header, + leading_cell_render + }); } -function switch_tournament(tournament_key, success_cb) { - send({ - type: 'tournament_get', - key: tournament_key, - }, function(err, response) { - if (err) { - return cerror.net(err); +function render_officials_by_filter(main, { + title = null, + officials, + table_id, + filter_fn, + sort_fn = null, + min_height_px = 240, + name_header = 'Name', + first_cell_render = null, + leading_header = null, + leading_cell_render = null +}) { + const rows = officials.filter(filter_fn); + if (sort_fn) { + rows.sort(sort_fn); + } else { + rows.sort((a, b) => cbts_utils.natcmp(a.name || '', b.name || '')); + } + + return render_officials_table(main, { + title, + rows, + table_id, + min_height_px, + name_header, + first_cell_render, + leading_header, + leading_cell_render + }); +} + +function render_official_role_split_section(main, { + title, + left_table_id, + right_table_id, + left_filter_fn, + right_filter_fn, + first_cell_render = null, + left_leading_header = null, + left_leading_cell_render = null, + sort_fn = null, + min_height_px = 240 +}) { + uiu.el(main, 'h3', 'edit', title); + const section_div = uiu.el(main, 'div', 'official_split_section'); + + const left_div = uiu.el(section_div, 'div', 'official_role_split_column'); + const left = render_officials_by_filter(left_div, { + officials: curt.umpires, + table_id: left_table_id, + filter_fn: left_filter_fn, + sort_fn, + min_height_px, + name_header: ci18n('Umpire'), + first_cell_render, + leading_header: left_leading_header, + leading_cell_render: left_leading_cell_render + }); + + uiu.el(section_div, 'div', 'official_role_split_space'); + + const right_div = uiu.el(section_div, 'div', 'official_role_split_column'); + const right = render_officials_by_filter(right_div, { + officials: curt.umpires, + table_id: right_table_id, + filter_fn: right_filter_fn, + sort_fn, + min_height_px, + name_header: ci18n('Service judge'), + first_cell_render + }); + + return { left, right }; +} + +function render_on_court_officials_table(main, { + rows, + table_id, + min_height_px = 240, + table_class = 'officials_table_on_court', + leading_cell_render = null +}) { + const table = uiu.el(main, 'div', `officials_dual_list ${table_class}`, { 'data-table-id': table_id }); + + const head = uiu.el(table, 'div', 'officials_dual_row officials_dual_header'); + uiu.el(head, 'div', 'officials_dual_cell officials_cell_leading', ''); + uiu.el(head, 'div', 'officials_dual_cell officials_cell_name', ci18n('Umpire')); + const headLeftUmpire = uiu.el(head, 'div', 'officials_dual_cell officials_cell_role'); + uiu.el(headLeftUmpire, 'div', { class: 'umpire' }); + const headLeftService = uiu.el(head, 'div', 'officials_dual_cell officials_cell_role'); + uiu.el(headLeftService, 'div', { class: 'service_judge' }); + uiu.el(head, 'div', 'officials_dual_cell officials_dual_center_space', ''); + uiu.el(head, 'div', 'officials_dual_cell officials_cell_name', ci18n('Service judge')); + const headRightUmpire = uiu.el(head, 'div', 'officials_dual_cell officials_cell_role'); + uiu.el(headRightUmpire, 'div', { class: 'umpire' }); + const headRightService = uiu.el(head, 'div', 'officials_dual_cell officials_cell_role'); + uiu.el(headRightService, 'div', { class: 'service_judge' }); + + for (const row of rows) { + const tr = uiu.el(table, 'div', 'officials_dual_row'); + if (row.is_inactive_court) { + tr.classList.add('officials_dual_row_inactive'); + } + if (row.match_id) { + tr.setAttribute('data-match-id', row.match_id); + } + const leftOfficial = row.left; + const rightOfficial = row.right; + + const leadingTd = uiu.el(tr, 'div', 'officials_dual_cell officials_cell_leading'); + if (leading_cell_render) { + leading_cell_render(leadingTd, row, leftOfficial, rightOfficial); + } else { + const courtClass = leftOfficial._is_empty_on_court_slot ? 'court officials_table_court_inactive' : 'court'; + uiu.el(leadingTd, 'div', courtClass, row.court_num || ''); + } + + const leftNameTd = uiu.el(tr, 'div', row.match_id ? { + class: 'officials_dual_cell officials_cell_name official_assignment_slot', + 'data-match-id': row.match_id, + 'data-role': 'umpire', + 'data-slot-group': `${row.match_id}:umpire`, + 'data-official-id': leftOfficial._id || '' + } : { class: 'officials_dual_cell officials_cell_name' }); + uiu.text(leftNameTd, leftOfficial.name || `${leftOfficial.firstname} ${leftOfficial.surname}`.trim()); + + const leftUmpireTd = uiu.el(tr, 'div', 'officials_dual_cell officials_cell_role'); + if (!row.match_id && !leftOfficial._id) { + // leerer On-Court-Slot: keine Checkbox anzeigen + } else if (!row.match_id || leftOfficial._id) { + create_official_role_checkbox(leftUmpireTd, leftOfficial, 'is_umpire'); + } else { + leftUmpireTd.classList.add('official_assignment_slot'); + leftUmpireTd.setAttribute('data-match-id', row.match_id); + leftUmpireTd.setAttribute('data-role', 'umpire'); + leftUmpireTd.setAttribute('data-slot-group', `${row.match_id}:umpire`); + leftUmpireTd.setAttribute('data-official-id', ''); + } + + const leftServiceTd = uiu.el(tr, 'div', 'officials_dual_cell officials_cell_role'); + if (!row.match_id && !leftOfficial._id) { + // leerer On-Court-Slot: keine Checkbox anzeigen + } else if (!row.match_id || leftOfficial._id) { + create_official_role_checkbox(leftServiceTd, leftOfficial, 'is_service_judge'); + } else { + leftServiceTd.classList.add('official_assignment_slot'); + leftServiceTd.setAttribute('data-match-id', row.match_id); + leftServiceTd.setAttribute('data-role', 'umpire'); + leftServiceTd.setAttribute('data-slot-group', `${row.match_id}:umpire`); + leftServiceTd.setAttribute('data-official-id', ''); + } + + uiu.el(tr, 'div', 'officials_dual_cell officials_dual_center_space', ''); + + const rightNameTd = uiu.el(tr, 'div', row.match_id ? { + class: 'officials_dual_cell officials_cell_name official_assignment_slot', + 'data-match-id': row.match_id, + 'data-role': 'service_judge', + 'data-slot-group': `${row.match_id}:service_judge`, + 'data-official-id': rightOfficial._id || '' + } : { class: 'officials_dual_cell officials_cell_name' }); + uiu.text(rightNameTd, rightOfficial.name || `${rightOfficial.firstname} ${rightOfficial.surname}`.trim()); + + const rightUmpireTd = uiu.el(tr, 'div', 'officials_dual_cell officials_cell_role'); + if (!row.match_id && !rightOfficial._id) { + // leerer On-Court-Slot: keine Checkbox anzeigen + } else if (!row.match_id || rightOfficial._id) { + create_official_role_checkbox(rightUmpireTd, rightOfficial, 'is_umpire'); + } else { + rightUmpireTd.classList.add('official_assignment_slot'); + rightUmpireTd.setAttribute('data-match-id', row.match_id); + rightUmpireTd.setAttribute('data-role', 'service_judge'); + rightUmpireTd.setAttribute('data-slot-group', `${row.match_id}:service_judge`); + rightUmpireTd.setAttribute('data-official-id', ''); + } + + const rightServiceTd = uiu.el(tr, 'div', 'officials_dual_cell officials_cell_role'); + if (!row.match_id && !rightOfficial._id) { + // leerer On-Court-Slot: keine Checkbox anzeigen + } else if (!row.match_id || rightOfficial._id) { + create_official_role_checkbox(rightServiceTd, rightOfficial, 'is_service_judge'); + } else { + rightServiceTd.classList.add('official_assignment_slot'); + rightServiceTd.setAttribute('data-match-id', row.match_id); + rightServiceTd.setAttribute('data-role', 'service_judge'); + rightServiceTd.setAttribute('data-slot-group', `${row.match_id}:service_judge`); + rightServiceTd.setAttribute('data-official-id', ''); + } + } + + return { table, tbody: table }; +} + +function enable_min_height_resize_recalc(tables) { + return tables; +} + +function enable_preparation_official_dragdrop(preparation_table, lower_tbodies, officialById) { + if (!preparation_table) return; + + const set_drag_meta = (meta) => { + window._dragged_official_meta = meta; + }; + const clear_drag_meta = () => { + window._dragged_official_meta = null; + }; + const set_slot_group_hover = (slot, active) => { + const group = slot.getAttribute('data-slot-group'); + if (!group) return; + preparation_table.querySelectorAll(`.official_assignment_slot[data-slot-group=${JSON.stringify(group)}]`).forEach((groupSlot) => { + groupSlot.classList.toggle('drop-zone-hover', !!active); + }); + }; + const set_slot_group_dragging = (slot, active) => { + const group = slot.getAttribute('data-slot-group'); + if (!group) return; + preparation_table.querySelectorAll(`.official_assignment_slot[data-slot-group=${JSON.stringify(group)}]`).forEach((groupSlot) => { + groupSlot.classList.toggle('dragging', !!active); + }); + }; + const set_lower_dropzones_active = (active) => { + for (const tbody of lower_tbodies) { + if (active) { + add_drop_zones_to_tbody(tbody, { + col_count: 3, + on_zone_dragover: () => {} + }); + } else { + remove_drop_zones_from_tbody(tbody); + } + } + }; + const remove_official_drag_image = () => { + if (official_drag_image_el && official_drag_image_el.parentNode) { + official_drag_image_el.parentNode.removeChild(official_drag_image_el); + } + official_drag_image_el = null; + }; + const create_official_drag_image = (slot) => { + remove_official_drag_image(); + const group = slot.getAttribute('data-slot-group'); + if (!group) return null; + const groupSlots = [...preparation_table.querySelectorAll(`.official_assignment_slot[data-slot-group=${JSON.stringify(group)}]`)]; + if (!groupSlots.length) return null; + + const table = document.createElement('table'); + table.className = 'official_drag_image_table'; + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + table.appendChild(tbody); + tbody.appendChild(tr); + + groupSlots.forEach((groupSlot) => { + const clone = groupSlot.cloneNode(true); + clone.classList.remove('drop-zone-hover'); + clone.classList.add('official_drag_image_cell'); + clone.style.width = `${Math.ceil(groupSlot.getBoundingClientRect().width)}px`; + clone.style.height = `${Math.ceil(groupSlot.getBoundingClientRect().height)}px`; + tr.appendChild(clone); + }); + + table.style.position = 'fixed'; + table.style.left = '-10000px'; + table.style.top = '-10000px'; + table.style.pointerEvents = 'none'; + table.style.zIndex = '9999'; + document.body.appendChild(table); + official_drag_image_el = table; + return table; + }; + + for (const tbody of lower_tbodies) { + for (const tr of tbody.querySelectorAll('.officials_row[data-official-id]')) { + tr.addEventListener('dragstart', () => { + const official_id = tr.getAttribute('data-official-id'); + if (!official_id) return; + set_drag_meta({ + source_type: 'list', + official_id, + from_table: tbody.getAttribute('data-table-id') || '', + official: officialById.get(official_id) || null + }); + preparation_table.classList.add('drop-zones-active'); + }); + tr.addEventListener('dragend', () => { + clear_drag_meta(); + preparation_table.classList.remove('drop-zones-active'); + preparation_table.querySelectorAll('.official_assignment_slot.drop-zone-hover').forEach((slot) => { + slot.classList.remove('drop-zone-hover'); + }); + }); + } + + tbody.addEventListener('dragover', (e) => { + const meta = window._dragged_official_meta; + if (!meta || meta.source_type !== 'match') return; + e.preventDefault(); + }); + tbody.addEventListener('drop', (e) => { + const meta = window._dragged_official_meta; + if (!meta || meta.source_type !== 'match') return; + e.preventDefault(); + clear_drag_meta(); + send_with_live_status({ + type: 'remove_official_from_preparation_match', + tournament_key: curt.key, + official_id: meta.official_id, + match_id: meta.match_id, + role: meta.role, + to_list: tbody.getAttribute('data-table-id') || '' + }, (err) => { + if (err) return cerror.net(err); + }); + }); + } + + for (const slot of preparation_table.querySelectorAll('.official_assignment_slot')) { + const official_id = slot.getAttribute('data-official-id'); + const match_id = slot.getAttribute('data-match-id'); + const role = slot.getAttribute('data-role'); + + if (official_id) { + slot.draggable = true; + slot.addEventListener('dragstart', (e) => { + set_drag_meta({ + source_type: 'match', + official_id, + match_id, + role + }); + set_slot_group_dragging(slot, true); + preparation_table.classList.add('drop-zones-active'); + set_lower_dropzones_active(true); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', official_id); + const dragImage = create_official_drag_image(slot); + if (dragImage) { + e.dataTransfer.setDragImage(dragImage, 10, 10); + } + } + }); + slot.addEventListener('dragend', () => { + clear_drag_meta(); + set_slot_group_dragging(slot, false); + preparation_table.classList.remove('drop-zones-active'); + set_lower_dropzones_active(false); + remove_official_drag_image(); + }); + } else { + slot.classList.add('drop-zone'); + } + + slot.addEventListener('dragover', (e) => { + const meta = window._dragged_official_meta; + if (!meta || meta.source_type !== 'list' || slot.getAttribute('data-official-id')) return; + if (!meta.official) return; + if (role === 'umpire' && !meta.official.is_umpire) return; + if (role === 'service_judge' && !meta.official.is_service_judge) return; + e.preventDefault(); + set_slot_group_hover(slot, true); + }); + + slot.addEventListener('dragenter', (e) => { + const meta = window._dragged_official_meta; + if (!meta || meta.source_type !== 'list' || slot.getAttribute('data-official-id')) return; + if (!meta.official) return; + if (role === 'umpire' && !meta.official.is_umpire) return; + if (role === 'service_judge' && !meta.official.is_service_judge) return; + e.preventDefault(); + set_slot_group_hover(slot, true); + }); + + slot.addEventListener('dragleave', () => { + set_slot_group_hover(slot, false); + }); + + slot.addEventListener('drop', (e) => { + const meta = window._dragged_official_meta; + if (!meta || meta.source_type !== 'list' || slot.getAttribute('data-official-id')) return; + if (!meta.official) return; + if (role === 'umpire' && !meta.official.is_umpire) return; + if (role === 'service_judge' && !meta.official.is_service_judge) return; + e.preventDefault(); + set_slot_group_hover(slot, false); + clear_drag_meta(); + set_slot_group_dragging(slot, false); + preparation_table.classList.remove('drop-zones-active'); + set_lower_dropzones_active(false); + remove_official_drag_image(); + skip_next_official_list_move = { + row_id: meta.official_id, + from_table: meta.from_table || '' + }; + send_with_live_status({ + type: 'assign_official_to_preparation_match', + tournament_key: curt.key, + official_id: meta.official_id, + match_id, + role + }, (err) => { + if (err) return cerror.net(err); + }); + }); + } +} + +function update_official_tables(officials_host) { + officials_host.innerHTML = ''; + + const official_rotation_mode = curt.official_rotation_mode || 'umpire_and_service_judge'; + const include_service_judges = official_rotation_mode === 'umpire_and_service_judge'; + const officials_div = uiu.el(officials_host, 'div', 'settings'); + if (!include_service_judges) { + officials_div.classList.add('official_rotation_mode_umpire_only'); + } + uiu.el(officials_div, 'h2', 'edit', ci18n('Technical officials rotation:')); + const official_rotation_mode_label = uiu.el(officials_div, 'label', 'official_rotation_mode_control'); + uiu.el(official_rotation_mode_label, 'span', {}, ci18n('tournament:edit:official_rotation_mode')); + const official_rotation_mode_select = uiu.el(official_rotation_mode_label, 'select', { + name: 'official_rotation_mode', + }); + [ + 'disabled', + 'umpire_only', + 'umpire_and_service_judge', + ].forEach((mode) => { + const attrs = { value: mode }; + if (official_rotation_mode === mode) { + attrs.selected = 'selected'; + } + uiu.el(official_rotation_mode_select, 'option', attrs, ci18n(`tournament:edit:official_rotation_mode:${mode}`)); + }); + bind_live_prop(official_rotation_mode_select, 'official_rotation_mode'); + + if (official_rotation_mode === 'disabled') { + return; + } + + const technical_official_auto_assignment_mode = curt.technical_official_auto_assignment_mode || 'manual_only'; + const technical_official_auto_assignment_mode_label = uiu.el(officials_div, 'label', 'official_rotation_mode_control'); + uiu.el(technical_official_auto_assignment_mode_label, 'span', {}, ci18n('tournament:edit:technical_official_auto_assignment_mode')); + const technical_official_auto_assignment_mode_select = uiu.el(technical_official_auto_assignment_mode_label, 'select', { + name: 'technical_official_auto_assignment_mode', + }); + [ + 'manual_only', + 'on_match_call_if_possible', + 'on_preparation_call', + 'when_available', + ].forEach((mode) => { + const attrs = { value: mode }; + if (technical_official_auto_assignment_mode === mode) { + attrs.selected = 'selected'; + } + uiu.el( + technical_official_auto_assignment_mode_select, + 'option', + attrs, + ci18n(`tournament:edit:technical_official_auto_assignment_mode:${mode}`) + ); + }); + bind_live_prop(technical_official_auto_assignment_mode_select, 'technical_official_auto_assignment_mode'); + + const technical_official_break_after_assignment_seconds_label = uiu.el(officials_div, 'label', 'official_rotation_mode_control'); + uiu.el(technical_official_break_after_assignment_seconds_label, 'span', {}, ci18n('tournament:edit:technical_official_break_after_assignment_seconds')); + const technical_official_break_after_assignment_seconds_input = uiu.el(technical_official_break_after_assignment_seconds_label, 'input', { + type: 'number', + name: 'technical_official_break_after_assignment_seconds', + value: curt.technical_official_break_after_assignment_seconds || 0, + min: 0, + max: 3600, + step: 1, + }); + bind_live_prop(technical_official_break_after_assignment_seconds_input, 'technical_official_break_after_assignment_seconds', { + get_value: input_el => Number(input_el.value), + }); + + const all_officials = (curt.umpires || []).filter((official) => { + if (!(official && official._id)) { + return false; + } + return include_service_judges || !!official.is_umpire; + }); + const officialById = new Map(all_officials.map((official) => [official._id, official])); + let dragged_meta = null; + const sorted_courts = [...(curt.courts || [])].sort((a, b) => cbts_utils.natcmp(String(a.num || ''), String(b.num || ''))); + const preparation_matches = [...(curt.matches || [])] + .filter((match) => (match.setup || {}).state === 'preparation') + .sort((a, b) => (a.setup?.preparation_call_timestamp || 0) - (b.setup?.preparation_call_timestamp || 0)); + const assigned_matches = [...(curt.matches || [])] + .filter((match) => { + const setup = match.setup || {}; + return setup.state !== 'preparation' + && !['oncourt', 'blocked', 'finished'].includes(setup.state) + && ((setup.umpire && setup.umpire._id) || (include_service_judges && setup.service_judge && setup.service_judge._id)); + }) + .sort((a, b) => cbts_utils.natcmp(String(a.setup?.match_num || ''), String(b.setup?.match_num || ''))); + const visible_official_ids = new Set(); + const mark_visible = (official) => { + if (official && official._id) { + visible_official_ids.add(official._id); + } + }; + + const official_display_name = (official) => official.name || `${official.firstname || ''} ${official.surname || ''}`.trim(); + const get_official_pause_sort_ts = (official, role) => { + const auto_pause = official?.[`${role}_pause`]; + if (auto_pause != null) { + return Number(auto_pause) || 0; + } + const manual_pause = official?.[`${role}_manual_pause`]; + return Number(manual_pause) || 0; + }; + const create_official_pause_timer_state = (official, role) => { + const pause_target_ts = Number(official?.[`${role}_pause`]); + if (!Number.isFinite(pause_target_ts)) { + return null; + } + const remaining_ms = Math.max(0, pause_target_ts - Date.now()); + return { + settings: { + negative_timers: false, + }, + lang: 'de', + timer: { + duration: remaining_ms, + start: Date.now(), + upwards: false, + exigent: false, + }, + bgColor: '#ff0000', + }; + }; + const official_card_variant_class = (variant, official = null) => { + switch (variant) { + case 'on_court': + return 'official_card_variant_on_court'; + case 'assignment': + return official && official.checked_in + ? 'official_card_variant_checked_in' + : 'official_card_variant_not_checked_in'; + case 'list': + default: + return 'official_card_variant_list'; + } + }; + const role_state_from_official = (official) => { + if (!!official.is_umpire && !!official.is_service_judge) return 'all'; + if (!!official.is_umpire) return 'umpire_only'; + if (!!official.is_service_judge) return 'service_only'; + return 'all'; + }; + const cycle_role_state = (official, current_state) => { + if (current_state === 'all') { + return official.is_umpire ? 'umpire_only' : 'service_only'; + } + if (current_state === 'umpire_only') return 'service_only'; + return 'all'; + }; + const clear_same_stack_active_drop = () => { + officials_div.querySelectorAll('.official_card_drop_hover_expand').forEach((el) => { + el.classList.remove('official_card_drop_hover_expand', 'official_card_drop_hover'); + }); + officials_div.querySelectorAll('.official_card_stack_has_nonterminal_hover').forEach((el) => { + el.classList.remove('official_card_stack_has_nonterminal_hover'); + }); + if (dragged_meta) { + dragged_meta.active_drop = null; + } + }; + const activate_drop_target = (drop) => { + if (!dragged_meta || !drop) return; + if (dragged_meta.active_drop === drop) return; + if (dragged_meta.active_drop) { + const previous_stack = dragged_meta.active_drop.parentElement; + if (previous_stack?.classList.contains('official_card_stack')) { + previous_stack.classList.remove('official_card_stack_has_nonterminal_hover'); + } + dragged_meta.active_drop.classList.remove('official_card_drop_hover', 'official_card_drop_hover_expand'); + } + dragged_meta.active_drop = drop; + drop.classList.add('official_card_drop_hover'); + if (drop.parentElement?.classList.contains('official_card_stack')) { + if (!drop.classList.contains('official_card_drop_terminal')) { + drop.parentElement.classList.add('official_card_stack_has_nonterminal_hover'); + } + drop.classList.add('official_card_drop_hover_expand'); + } + }; + const set_source_placeholder_state = (state) => { + if (!dragged_meta?.source_card) return; + const card = dragged_meta.source_card; + const in_stack = !!dragged_meta.source_stack; + card.classList.remove( + 'official_card_placeholder_compact', + 'official_card_placeholder_activelike', + 'official_card_placeholder_hoverlike' + ); + if (state === 'hover') { + card.classList.add('official_card_placeholder_hoverlike'); + return; + } + if (state === 'active') { + card.classList.add('official_card_placeholder_activelike'); + if (in_stack) { + card.classList.add('official_card_placeholder_compact'); + } + } + }; + const set_drop_highlight = (drop, active) => { + if (drop.classList.contains('official_card_drop_suppressed')) { + drop.classList.remove('official_card_drop_hover', 'official_card_drop_hover_expand'); + return; + } + const same_stack = !!dragged_meta?.source_stack && drop.parentElement === dragged_meta.source_stack; + if (!same_stack) { + if (active) { + activate_drop_target(drop); + set_source_placeholder_state('active'); + } else if (dragged_meta?.active_drop !== drop) { + drop.classList.remove('official_card_drop_hover', 'official_card_drop_hover_expand'); + } + document.querySelectorAll('.official_card_drop_suppressed').forEach((el) => { + el.classList.remove('official_card_drop_suppressed'); + }); + if (!dragged_meta?.active_drop) { + set_source_placeholder_state('hover'); + } + update_source_placeholder_suppression(); + return; + } + if (active) { + activate_drop_target(drop); + update_source_placeholder_suppression(); + set_source_placeholder_state('active'); + } else { + if (!dragged_meta?.active_drop) { + set_source_placeholder_state('hover'); + document.querySelectorAll('.official_card_drop_suppressed').forEach((el) => { + el.classList.remove('official_card_drop_suppressed'); + }); + update_source_placeholder_suppression(); + } + } + }; + const register_drop_target = (drop, can_drop) => { + drop._officialCanDrop = can_drop; + }; + const update_source_placeholder_suppression = () => { + document.querySelectorAll('.official_card_drop_suppressed').forEach((drop) => { + drop.classList.remove('official_card_drop_suppressed'); + }); + if (!dragged_meta?.source_card) { + return; + } + const card = dragged_meta.source_card; + if (card.previousElementSibling && card.previousElementSibling.classList.contains('official_card_drop')) { + card.previousElementSibling.classList.add('official_card_drop_suppressed'); + } + if (card.nextElementSibling && card.nextElementSibling.classList.contains('official_card_drop')) { + card.nextElementSibling.classList.add('official_card_drop_suppressed'); + } + }; + const move_placeholder_relative_to_card = (target_card, place_after) => { + if (!dragged_meta?.source_stack) { + return; + } + if (target_card.parentElement !== dragged_meta.source_stack) { + return; + } + const target_drop = place_after + ? target_card.nextElementSibling + : target_card.previousElementSibling; + if (target_drop && target_drop.classList.contains('official_card_drop')) { + set_drop_highlight(target_drop, true); + } + }; + const set_all_drop_targets_active = (active) => { + officials_div.classList.toggle('officials_drag_active', !!active && !!dragged_meta); + officials_div.querySelectorAll('.official_card_drop').forEach((drop) => { + if (drop.classList.contains('official_card_drop_suppressed')) { + drop.classList.remove('official_card_drop_active'); + return; + } + const can_drop = typeof drop._officialCanDrop === 'function' ? drop._officialCanDrop(dragged_meta) : false; + drop.classList.toggle('official_card_drop_active', !!active && !!dragged_meta && can_drop); + }); + }; + const clear_all_drop_states = () => { + officials_div.classList.remove('officials_drag_active'); + document.querySelectorAll('.official_card_drop_active').forEach((drop) => { + drop.classList.remove('official_card_drop_active'); + }); + document.querySelectorAll('.official_card_drop_hover').forEach((drop) => { + drop.classList.remove('official_card_drop_hover'); + }); + document.querySelectorAll('.official_card_drop_hover_expand').forEach((drop) => { + drop.classList.remove('official_card_drop_hover_expand'); + }); + document.querySelectorAll('.official_card_placeholder').forEach((card) => { + card.classList.remove('official_card_placeholder'); + }); + document.querySelectorAll('.official_card_placeholder_compact').forEach((card) => { + card.classList.remove('official_card_placeholder_compact'); + }); + document.querySelectorAll('.official_card_placeholder_hoverlike').forEach((card) => { + card.classList.remove('official_card_placeholder_hoverlike'); + }); + document.querySelectorAll('.official_card_drop_suppressed').forEach((drop) => { + drop.classList.remove('official_card_drop_suppressed'); + }); + document.querySelectorAll('.official_card_stack_has_nonterminal_hover').forEach((stack) => { + stack.classList.remove('official_card_stack_has_nonterminal_hover'); + }); + document.querySelectorAll('.official_section_body_stacklist').forEach((section) => { + section.style.height = ''; + }); + }; + const freeze_stack_section_heights = () => { + officials_div.querySelectorAll('.official_section_body_stacklist').forEach((section) => { + section.style.height = `${Math.ceil(section.getBoundingClientRect().height)}px`; + }); + }; + const apply_stack_section_base_heights = () => { + officials_div.querySelectorAll('.official_section_body_stacklist').forEach((section) => { + section.style.height = ''; + section.style.minHeight = ''; + const content_height = section.scrollHeight; + const bottom_buffer_px = 6; + section.style.minHeight = `${Math.ceil(content_height + bottom_buffer_px)}px`; + }); + }; + const clear_drop_states_global = () => { + dragged_meta = null; + official_drag_active = false; + clear_all_drop_states(); + if (official_drag_refresh_pending) { + official_drag_refresh_pending = false; + setTimeout(() => { + if (current_view === 'edit') { + ctournament.update_officials(); + } + }, 0); + } + }; + const document_drop_cleanup = () => setTimeout(() => clear_drop_states_global(), 0); + const window_dragend_cleanup = () => clear_drop_states_global(); + document.addEventListener('drop', document_drop_cleanup, true); + window.addEventListener('dragend', window_dragend_cleanup, true); + update_official_tables._document_drop_cleanup = document_drop_cleanup; + update_official_tables._window_dragend_cleanup = window_dragend_cleanup; + const get_stack_card_ids = (stack) => [...stack.querySelectorAll('.official_card_frame[data-official-id]')].map((card) => card.getAttribute('data-official-id')).filter(Boolean); + const move_meta_to_list = (stack, meta, to_list, error_handler = cerror.net) => { + const ids = get_stack_card_ids(stack); + const virtual_ids = ids.filter((id) => id !== meta.official_id); + virtual_ids.push(meta.official_id); + const idx = virtual_ids.indexOf(meta.official_id); + const prev_id = idx > 0 ? virtual_ids[idx - 1] : null; + const next_id = idx >= 0 && idx < virtual_ids.length - 1 ? virtual_ids[idx + 1] : null; + send_with_live_status({ + type: 'official_list_move', + tournament_key: curt.key, + official_id: meta.official_id, + from_list: meta.from_list, + to_list, + prev_btp_id: prev_id ? officialById.get(prev_id)?.btp_id : null, + next_btp_id: next_id ? officialById.get(next_id)?.btp_id : null + }, (err) => { + if (err) return error_handler(err); + }); + }; + const remove_meta_from_match_to_list = (stack, meta, to_list, before_official_id = null, error_handler = cerror.net) => { + const type = meta.source_type === 'assigned' + ? 'remove_official_from_match' + : 'remove_official_from_preparation_match'; + const ids = get_stack_card_ids(stack); + const virtual_ids = ids.filter((id) => id !== meta.official_id); + const insert_at = before_official_id ? virtual_ids.indexOf(before_official_id) : virtual_ids.length; + if (insert_at < 0) { + virtual_ids.push(meta.official_id); + } else { + virtual_ids.splice(insert_at, 0, meta.official_id); + } + send_with_live_status({ + type, + tournament_key: curt.key, + official_id: meta.official_id, + match_id: meta.match_id, + role: meta.role, + to_list, + ordered_official_ids: virtual_ids + }, (err) => { + if (err) return error_handler(err); + }); + }; + const render_official_card = (parent, official, icon_class, drag_meta_factory = null, stack_drop_options = null, variant = 'list', timer_state = null) => { + const card = uiu.el(parent, 'div', `official_card_frame official_card_skin ${official_card_variant_class(variant, official)}`); + card.setAttribute('data-official-id', official._id); + card.draggable = !!drag_meta_factory; + uiu.el(card, 'div', `official_card_icon ${icon_class}`); + uiu.el(card, 'div', 'official_card_name', official_display_name(official)); + if (timer_state) { + const timer_host = uiu.el(card, 'div', 'official_card_timer'); + cmatch.create_timer(timer_state, timer_host, '#ffffff', '#ffffff'); + } + const icon_trail = uiu.el(card, 'div', 'official_card_trail'); + icon_trail.setAttribute('data-state', role_state_from_official(official)); + icon_trail.draggable = false; + uiu.el(icon_trail, 'div', 'official_card_trail_icon umpire'); + uiu.el(icon_trail, 'div', 'official_card_trail_swap', '⇄'); + uiu.el(icon_trail, 'div', 'official_card_trail_icon service_judge'); + icon_trail.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + icon_trail.addEventListener('dragstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + icon_trail.addEventListener('click', (e) => { + e.stopPropagation(); + const current = icon_trail.getAttribute('data-state') || 'all'; + const next = cycle_role_state(official, current); + const previous_values = { + is_umpire: !!official.is_umpire, + is_service_judge: !!official.is_service_judge + }; + icon_trail.setAttribute('data-state', next); + const next_values = next === 'all' + ? { is_umpire: true, is_service_judge: true } + : next === 'umpire_only' + ? { is_umpire: true, is_service_judge: false } + : { is_umpire: false, is_service_judge: true }; + official.is_umpire = next_values.is_umpire; + official.is_service_judge = next_values.is_service_judge; + set_pending_official_role_override(official._id, next_values); + ctournament.update_officials(); + send_with_live_status({ + type: 'official_roles_edit', + tournament_key: curt.key, + official_id: official._id, + is_umpire: next_values.is_umpire, + is_service_judge: next_values.is_service_judge + }, (err) => { + if (err) { + official.is_umpire = previous_values.is_umpire; + official.is_service_judge = previous_values.is_service_judge; + set_pending_official_role_override(official._id, previous_values); + ctournament.update_officials(); + return cerror.net(err); + } + }); + }); + if (drag_meta_factory) { + card.addEventListener('dragstart', (e) => { + if (icon_trail.contains(e.target)) { + e.preventDefault(); + return; + } + dragged_meta = drag_meta_factory(official); + official_drag_active = true; + dragged_meta.source_card = card; + dragged_meta.source_stack = parent.classList.contains('official_card_stack') ? parent : null; + dragged_meta.active_drop = null; + let drag_image = null; + if (e.dataTransfer) { + drag_image = card.cloneNode(true); + drag_image.classList.remove('official_card_dragging', 'official_card_placeholder'); + drag_image.style.position = 'fixed'; + drag_image.style.top = '-1000px'; + drag_image.style.left = '-1000px'; + drag_image.style.pointerEvents = 'none'; + drag_image.style.width = `${card.offsetWidth}px`; + drag_image.style.height = `${card.offsetHeight}px`; + document.body.appendChild(drag_image); + } + card.classList.add('official_card_dragging', 'official_card_placeholder'); + set_source_placeholder_state('hover'); + set_all_drop_targets_active(true); + update_source_placeholder_suppression(); + freeze_stack_section_heights(); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', official._id); + e.dataTransfer.setDragImage(drag_image, 20, 19); + setTimeout(() => drag_image.remove(), 0); + } + }); + card.addEventListener('dragend', () => { + card.classList.remove('official_card_dragging'); + dragged_meta = null; + clear_all_drop_states(); + }); + } + if (parent.classList.contains('official_card_stack')) { + card.addEventListener('dragover', (e) => { + if (!dragged_meta) { + return; + } + const same_stack_drag = !!dragged_meta.source_stack && dragged_meta.source_stack === parent; + const can_drop_here = stack_drop_options && stack_drop_options.can_drop(dragged_meta); + if (!same_stack_drag && !can_drop_here) { + return; + } + if (dragged_meta.source_card === card) { + e.preventDefault(); + clear_same_stack_active_drop(); + set_source_placeholder_state('hover'); + update_source_placeholder_suppression(); + return; + } + e.preventDefault(); + const rect = card.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const deadzone = Math.min(8, rect.height * 0.2); + if (Math.abs(e.clientY - midpoint) <= deadzone && dragged_meta.active_drop) { + return; + } + const place_after = e.clientY > midpoint; + move_placeholder_relative_to_card(card, place_after); + }); + card.addEventListener('drop', (e) => { + if (!dragged_meta || !stack_drop_options || !stack_drop_options.can_drop(dragged_meta)) { + return; + } + if (dragged_meta.source_card === card) { + e.preventDefault(); + clear_all_drop_states(); + return; + } + e.preventDefault(); + const rect = card.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const place_after = e.clientY > midpoint; + let before_official_id = official._id; + if (place_after) { + const cards_in_stack = [...parent.querySelectorAll('.official_card_frame[data-official-id]')]; + const current_index = cards_in_stack.indexOf(card); + const next_card = current_index >= 0 ? cards_in_stack[current_index + 1] : null; + before_official_id = next_card ? next_card.getAttribute('data-official-id') : null; + } + clear_all_drop_states(); + stack_drop_options.on_drop(dragged_meta, before_official_id); + }); + } + return card; + }; + const render_card_stack_with_drops = (parent, icon_class, officials, options) => { + const append_drop = (before_official_id = null) => { + const drop = uiu.el(parent, 'div', 'official_card_frame official_card_drop'); + if (before_official_id == null) { + drop.classList.add('official_card_drop_terminal'); + } + register_drop_target(drop, options.can_drop); + drop.addEventListener('dragover', (e) => { + if (!dragged_meta || !options.can_drop(dragged_meta)) return; + e.preventDefault(); + set_drop_highlight(drop, true); + }); + drop.addEventListener('dragleave', () => { + set_drop_highlight(drop, false); + }); + drop.addEventListener('drop', (e) => { + if (!dragged_meta || !options.can_drop(dragged_meta)) return; + e.preventDefault(); + set_drop_highlight(drop, false); + clear_all_drop_states(); + options.on_drop(dragged_meta, before_official_id); + }); + return drop; + }; + const filtered_officials = officials.filter(Boolean); + append_drop(filtered_officials[0]?._id || null); + filtered_officials.forEach((official, index) => { + render_official_card( + parent, + official, + icon_class, + options.drag_meta_factory, + options, + options.variant || 'list', + options.timer_state_factory ? options.timer_state_factory(official) : null + ); + append_drop(filtered_officials[index + 1]?._id || null); + }); + }; + const create_official_section = (title) => { + uiu.el(officials_div, 'h3', 'edit', title); + return uiu.el(officials_div, 'div', 'official_section_body'); + }; + const send_list_reorder = (meta, to_list, virtual_ids, error_handler = cerror.net) => { + const idx = virtual_ids.indexOf(meta.official_id); + const prev_id = idx > 0 ? virtual_ids[idx - 1] : null; + const next_id = idx >= 0 && idx < virtual_ids.length - 1 ? virtual_ids[idx + 1] : null; + send_with_live_status({ + type: 'official_list_move', + tournament_key: curt.key, + official_id: meta.official_id, + from_list: meta.from_list, + to_list, + ordered_official_ids: virtual_ids, + prev_official_id: prev_id, + next_official_id: next_id, + prev_btp_id: prev_id ? (officialById.get(prev_id)?.btp_id ?? null) : null, + next_btp_id: next_id ? (officialById.get(next_id)?.btp_id ?? null) : null + }, (err) => { + if (err) return error_handler(err); + }); + }; + const build_reordered_ids = (stack, official_id, before_official_id = null) => { + const ids = get_stack_card_ids(stack); + const virtual_ids = ids.filter((id) => id !== official_id); + const insert_at = before_official_id ? virtual_ids.indexOf(before_official_id) : virtual_ids.length; + if (insert_at < 0) { + virtual_ids.push(official_id); + } else { + virtual_ids.splice(insert_at, 0, official_id); + } + return virtual_ids; + }; + const meta_can_fill_role = (meta, role) => { + if (!meta) return false; + const official = meta.official || officialById.get(meta.official_id); + if (role === 'umpire') return !!official?.is_umpire; + if (role === 'service_judge') return !!official?.is_service_judge; + return false; + }; + const role_from_list_name = (list_name) => { + if (list_name === 'umpire_wait' || list_name === 'umpire_pause') return 'umpire'; + if (list_name === 'service_judge_wait' || list_name === 'service_judge_pause') return 'service_judge'; + return null; + }; + const meta_can_drop_to_list = (meta, to_list) => { + if (!meta) return false; + if (to_list === 'inactive_list') { + return meta.source_type === 'list' || meta.source_type === 'preparation' || meta.source_type === 'assigned'; + } + const role = role_from_list_name(to_list); + return !!role && meta_can_fill_role(meta, role); + }; + const render_match_assignment_row = (section, match, source_type, assign_type) => { + const setup = match.setup || {}; + mark_visible(setup.umpire); + if (include_service_judges) { + mark_visible(setup.service_judge); + } + const row = uiu.el(section, 'div', `official_on_court_row${include_service_judges ? '' : ' official_on_court_row_single_role'}`); + const leading = uiu.el(row, 'div', 'official_on_court_leading official_preparation_leading'); + uiu.text(leading, `#${setup.match_num || ''}`); + const render_assignment_slot = (role, icon_class) => { + const slot = uiu.el(row, 'div', 'official_card_frame official_card_drop'); + const slot_enabled = () => role !== 'service_judge' || !!(setup.umpire && setup.umpire._id); + register_drop_target(slot, (meta) => { + if (!slot_enabled()) return false; + if (!meta || !['list', 'preparation', 'assigned'].includes(meta.source_type)) return false; + if (!meta_can_fill_role(meta, role)) return false; + if (slot.querySelector('[data-official-id]')) return false; + return true; + }); + if (!slot_enabled() && !setup[role]) { + slot.classList.add('official_card_drop_disabled'); + } + slot.addEventListener('dragover', (e) => { + if (!dragged_meta || !['list', 'preparation', 'assigned'].includes(dragged_meta.source_type)) return; + if (!slot_enabled()) return; + if (!meta_can_fill_role(dragged_meta, role)) return; + if (slot.querySelector('[data-official-id]')) return; + e.preventDefault(); + set_drop_highlight(slot, true); + }); + slot.addEventListener('dragleave', () => set_drop_highlight(slot, false)); + slot.addEventListener('drop', (e) => { + if (!dragged_meta || !['list', 'preparation', 'assigned'].includes(dragged_meta.source_type)) return; + if (!slot_enabled()) return; + if (!meta_can_fill_role(dragged_meta, role)) return; + if (slot.querySelector('[data-official-id]')) return; + e.preventDefault(); + set_drop_highlight(slot, false); + clear_all_drop_states(); + const payload = { + type: assign_type, + tournament_key: curt.key, + official_id: dragged_meta.official_id, + match_id: match._id, + role + }; + if (dragged_meta.source_type === 'preparation' || dragged_meta.source_type === 'assigned') { + payload.source_match_id = dragged_meta.match_id; + payload.source_type = dragged_meta.source_type; + payload.source_role = dragged_meta.role; + } + send_with_live_status(payload, (err) => { + if (err) return cerror.net(err); + }); + }); + const official = setup[role]; + if (official) { + const live_official = officialById.get(official._id) || official; + const assignment_official = { + ...live_official, + checked_in: official.checked_in + }; + render_official_card( + slot, + assignment_official, + icon_class, + (assigned_official) => ({ + source_type, + official_id: assigned_official._id, + official: assignment_official, + match_id: match._id, + role + }), + null, + 'assignment' + ); + } + }; + render_assignment_slot('umpire', 'umpire'); + if (include_service_judges) { + render_assignment_slot('service_judge', 'service_judge'); + } + }; + const render_vertical_list_section = (title, specs, row_class = '') => { + const section = create_official_section(title); + section.classList.add('official_section_body_stacklist'); + const row = uiu.el(section, 'div', `official_on_court_row${include_service_judges ? '' : ' official_on_court_row_single_role'}${row_class ? ' ' + row_class : ''}`); + uiu.el(row, 'div', 'official_on_court_leading official_preparation_leading'); + specs.forEach((spec) => { + const stack = uiu.el(row, 'div', 'official_card_stack'); + spec.items.forEach(mark_visible); + render_card_stack_with_drops(stack, spec.icon_class, spec.items, { + can_drop: (meta) => meta_can_drop_to_list(meta, spec.to_list), + drag_meta_factory: (official) => ({ source_type: 'list', official_id: official._id, from_list: spec.to_list, official }), + variant: spec.variant || 'list', + timer_state_factory: spec.timer_state_factory, + on_drop: (meta, before_official_id) => { + if (meta.source_type === 'preparation' || meta.source_type === 'assigned') { + remove_meta_from_match_to_list(stack, meta, spec.to_list, before_official_id); + return; + } + send_list_reorder(meta, spec.to_list, build_reordered_ids(stack, meta.official_id, before_official_id)); + } + }); + }); + return { section, row }; + }; + const on_court_placeholder_row = create_official_section(ci18n('On court:')); + sorted_courts.forEach((court) => { + const court_umpire = curt.umpires.find((official) => official.umpire_on_court === court._id); + const court_service_judge = include_service_judges + ? curt.umpires.find((official) => official.service_judge_on_court === court._id) + : null; + const has_match_on_court = !!court.match_id; + mark_visible(court_umpire); + if (include_service_judges) { + mark_visible(court_service_judge); + } + const court_row = uiu.el(on_court_placeholder_row, 'div', `official_on_court_row${include_service_judges ? '' : ' official_on_court_row_single_role'}`); + if (court.is_active === false) { + court_row.classList.add('official_on_court_row_inactive'); + } + const court_leading = uiu.el(court_row, 'div', 'official_on_court_leading'); + const is_active_court = court.is_active !== false; + const should_dim_court_icon = !has_match_on_court || !is_active_court; + const court_icon_class = [ + is_active_court ? 'court' : 'court_inactive', + should_dim_court_icon ? 'officials_table_court_inactive' : '' + ].filter(Boolean).join(' '); + uiu.el(court_leading, 'div', court_icon_class, is_active_court ? String(court.num || '') : ''); + const umpire_slot = uiu.el(court_row, 'div', 'official_on_court_slot official_card_frame official_card_drop official_card_drop_disabled'); + const service_judge_slot = include_service_judges + ? uiu.el(court_row, 'div', 'official_on_court_slot official_card_frame official_card_drop official_card_drop_disabled') + : null; + register_drop_target(umpire_slot, () => false); + if (service_judge_slot) { + register_drop_target(service_judge_slot, () => false); + } + if (!has_match_on_court) { + umpire_slot.classList.add('official_card_drop_disabled'); + if (service_judge_slot) { + service_judge_slot.classList.add('official_card_drop_disabled'); + } + } + if (court_umpire) { + render_official_card( + umpire_slot, + court_umpire, + 'umpire', + null, + null, + 'on_court' + ); + } + if (court_service_judge && service_judge_slot) { + render_official_card( + service_judge_slot, + court_service_judge, + 'service_judge', + null, + null, + 'on_court' + ); + } + }); + if (preparation_matches.length > 0) { + const in_preparation_section = create_official_section(ci18n('In preparation:')); + preparation_matches.forEach((match) => { + render_match_assignment_row(in_preparation_section, match, 'preparation', 'assign_official_to_preparation_match'); + }); + } + if (assigned_matches.length > 0) { + const assigned_section = create_official_section(ci18n('Assigned to a match:')); + assigned_matches.forEach((match) => { + render_match_assignment_row(assigned_section, match, 'assigned', 'assign_official_to_match'); + }); + } + const should_render_in_lower_lists = (official) => !visible_official_ids.has(official._id); + const waiting_umpires = [...(curt.umpires || [])] + .filter((official) => official.umpire_wait && should_render_in_lower_lists(official)) + .sort((a, b) => (a.umpire_wait || 0) - (b.umpire_wait || 0)); + const waiting_service_judges = include_service_judges + ? [...(curt.umpires || [])] + .filter((official) => official.service_judge_wait && should_render_in_lower_lists(official)) + .sort((a, b) => (a.service_judge_wait || 0) - (b.service_judge_wait || 0)) + : []; + const paused_umpires = [...(curt.umpires || [])] + .filter((official) => (official.umpire_pause != null || official.umpire_manual_pause != null) && should_render_in_lower_lists(official)) + .sort((a, b) => get_official_pause_sort_ts(a, 'umpire') - get_official_pause_sort_ts(b, 'umpire')); + const paused_service_judges = include_service_judges + ? [...(curt.umpires || [])] + .filter((official) => (official.service_judge_pause != null || official.service_judge_manual_pause != null) && should_render_in_lower_lists(official)) + .sort((a, b) => get_official_pause_sort_ts(a, 'service_judge') - get_official_pause_sort_ts(b, 'service_judge')) + : []; + const inactive_officials = [...(curt.umpires || [])] + .filter((official) => official.inactive_list && should_render_in_lower_lists(official)) + .sort((a, b) => (a.inactive_list || 0) - (b.inactive_list || 0)); + render_vertical_list_section(ci18n('Waiting for the next game:'), [ + { items: waiting_umpires, icon_class: 'umpire', to_list: 'umpire_wait' }, + ...(include_service_judges ? [{ items: waiting_service_judges, icon_class: 'service_judge', to_list: 'service_judge_wait' }] : []) + ]); + render_vertical_list_section(ci18n('Currently on break:'), [ + { items: paused_umpires, icon_class: 'umpire', to_list: 'umpire_pause', timer_state_factory: (official) => create_official_pause_timer_state(official, 'umpire') }, + ...(include_service_judges ? [{ items: paused_service_judges, icon_class: 'service_judge', to_list: 'service_judge_pause', timer_state_factory: (official) => create_official_pause_timer_state(official, 'service_judge') }] : []) + ]); + const inactive_section = create_official_section(ci18n('Not available:')); + inactive_section.classList.add('official_section_body_stacklist'); + const inactive_row = uiu.el(inactive_section, 'div', 'official_on_court_row official_inactive_row'); + uiu.el(inactive_row, 'div', 'official_on_court_leading official_preparation_leading'); + const inactive_stack = uiu.el(inactive_row, 'div', 'official_card_stack'); + const fallback_inactive_officials = all_officials + .filter((official) => !visible_official_ids.has(official._id)) + .filter((official) => official.umpire_wait == null && official.service_judge_wait == null && official.umpire_pause == null && official.service_judge_pause == null && official.umpire_manual_pause == null && official.service_judge_manual_pause == null && official.inactive_list == null) + .sort((a, b) => cbts_utils.natcmp(String(official_display_name(a)), String(official_display_name(b)))); + const all_inactive_officials = [...inactive_officials]; + fallback_inactive_officials.forEach((official) => { + if (!all_inactive_officials.some((existing) => existing._id === official._id)) { + all_inactive_officials.push(official); + } + }); + render_card_stack_with_drops(inactive_stack, 'umpire', all_inactive_officials, { + can_drop: (meta) => meta_can_drop_to_list(meta, 'inactive_list'), + drag_meta_factory: (official) => ({ source_type: 'list', official_id: official._id, from_list: 'inactive_list', official }), + on_drop: (meta, before_official_id) => { + if (meta.source_type === 'preparation' || meta.source_type === 'assigned') { + remove_meta_from_match_to_list(inactive_stack, meta, 'inactive_list', before_official_id); + return; + } + send_list_reorder(meta, 'inactive_list', build_reordered_ids(inactive_stack, meta.official_id, before_official_id)); + } + }); + apply_stack_section_base_heights(); +} + +function update_officials() { + if(current_view === 'edit') { + if (official_drag_active) { + official_drag_refresh_pending = true; + return; + } + if (update_official_tables._document_drop_cleanup) { + document.removeEventListener('drop', update_official_tables._document_drop_cleanup, true); + update_official_tables._document_drop_cleanup = null; + } + if (update_official_tables._window_dragend_cleanup) { + window.removeEventListener('dragend', update_official_tables._window_dragend_cleanup, true); + update_official_tables._window_dragend_cleanup = null; + } + update_official_tables(document.getElementById('officials_host')); + } + return; +} + + + function render_courts(main) { + uiu.el(main, 'h2', 'edit', ci18n('tournament:edit:courts')); + + const courts_table = uiu.el(main, 'table', 'courts_table'); + const courts_tbody = uiu.el(courts_table, 'tbody'); + const tr = uiu.el(courts_tbody, 'tr'); + uiu.el(tr, 'th', {}, 'Spielort'); + uiu.el(tr, 'th', {}, 'Nummer'); + //uiu.el(tr, 'th', {}, 'Name'); + uiu.el(tr, 'th', {}, 'Aktiv'); + uiu.el(tr, 'th', {}, 'Schiedsrichter'); + uiu.el(tr, 'th', {}, 'Aufschlagrichter'); + uiu.el(tr, 'th', {}, ''); + + var l = {_id : ''}; + + for (const c of curt.courts) { + const tr = uiu.el(courts_tbody, 'tr'); + if(l._id != c.location_id) { + l = utils.find(curt.locations, l => l._id === c.location_id); + } + + uiu.el(tr, 'th', {}, l.name); + uiu.el(tr, 'th', {}, c.num); + //uiu.el(tr, 'td', {}, c.name || ''); + const active_td = uiu.el(tr, 'td', {}); + const active_cb = create_simple_checkbox(active_td, {'name' : 'active_cb', 'data-court-id': c._id,}, c.is_active); + active_cb.addEventListener('change', (e) => { + const court_id = e.target.getAttribute('data-court-id'); + send_with_live_status({ + type: 'court_edit', + tournament_key: curt.key, + is_active: e.target.checked, + court_id: court_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + const umpire_td = uiu.el(tr, 'td', {}); + const umpire_cb = create_simple_checkbox(umpire_td, {'name' : 'umpire_cb', 'data-court-id': c._id}, c.has_umpire !== false); + umpire_cb.addEventListener('change', (e) => { + const court_id = e.target.getAttribute('data-court-id'); + const court = utils.find(curt.courts, (entry) => entry._id === court_id); + if (court) { + court.has_umpire = e.target.checked; + update_court(court); + } + send_with_live_status({ + type: 'court_edit', + tournament_key: curt.key, + has_umpire: e.target.checked, + court_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + const service_judge_td = uiu.el(tr, 'td', {}); + const service_judge_cb = create_simple_checkbox(service_judge_td, {'name' : 'service_judge_cb', 'data-court-id': c._id}, c.has_service_judge !== false); + service_judge_cb.addEventListener('change', (e) => { + const court_id = e.target.getAttribute('data-court-id'); + const court = utils.find(curt.courts, (entry) => entry._id === court_id); + if (court) { + court.has_service_judge = e.target.checked; + update_court(court); + } + send_with_live_status({ + type: 'court_edit', + tournament_key: curt.key, + has_service_judge: e.target.checked, + court_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + const actions_td = uiu.el(tr, 'td', {}); + const del_btn = uiu.el(actions_td, 'button', { + 'data-court-id': c._id, + }, 'Delete'); + del_btn.addEventListener('click', function (e) { + const del_btn = e.target; + const court_id = del_btn.getAttribute('data-court-id'); + if (confirm('Do you really want to delete ' + court_id + '? (Will not do anything yet!)')) { + debug.log('TODO: would now delete court'); + } + }); + } + + const nums = curt.courts.map(c => parseInt(c.num)); + const maxnum = Math.max(0, Math.max.apply(null, nums)); + apply_court_official_checkbox_dependencies(); + } + + function create_simple_checkbox(parant_el, attrs, is_checked) { + attrs.type = 'checkbox'; + if(is_checked){ + attrs.checked = 'checked'; + } + const result = uiu.el(parant_el, 'input', attrs); + return result; + } + + function get_effective_court_official_checkbox_state(court) { + const rotation_mode = curt.official_rotation_mode || 'umpire_and_service_judge'; + const is_active = court && court.is_active !== false; + const stored_has_umpire = court && court.has_umpire !== false; + const stored_has_service_judge = court && court.has_service_judge !== false; + + if (!is_active) { + return { + umpire_checked: false, + umpire_disabled: true, + service_judge_checked: false, + service_judge_disabled: true, + }; + } + + if (rotation_mode === 'disabled') { + return { + umpire_checked: false, + umpire_disabled: true, + service_judge_checked: false, + service_judge_disabled: true, + }; + } + + if (rotation_mode === 'umpire_only') { + return { + umpire_checked: stored_has_umpire, + umpire_disabled: false, + service_judge_checked: false, + service_judge_disabled: true, + }; + } + + return { + umpire_checked: stored_has_umpire, + umpire_disabled: false, + service_judge_checked: stored_has_umpire ? stored_has_service_judge : false, + service_judge_disabled: !stored_has_umpire, + }; + } + + function apply_court_official_checkbox_dependencies(court = null) { + const courts_table = uiu.qs('.courts_table'); + if (!courts_table) { + return; + } + + const courts = court ? [court] : (curt.courts || []); + courts.forEach((current_court) => { + if (!current_court || !current_court._id) { + return; + } + const state = get_effective_court_official_checkbox_state(current_court); + const umpire_checkbox = courts_table.querySelector(`[name="umpire_cb"][data-court-id="${current_court._id}"]`); + if (umpire_checkbox) { + umpire_checkbox.checked = !!state.umpire_checked; + umpire_checkbox.disabled = !!state.umpire_disabled; + } + const service_judge_checkbox = courts_table.querySelector(`[name="service_judge_cb"][data-court-id="${current_court._id}"]`); + if (service_judge_checkbox) { + service_judge_checkbox.checked = !!state.service_judge_checked; + service_judge_checkbox.disabled = !!state.service_judge_disabled; + } + }); + } + + function update_court(court) { + switch (get_admin_subpage()){ + case 'edit': + const courts_table = uiu.qs('.courts_table'); + if (!courts_table || !court) { + break; + } + const active_checkbox = courts_table.querySelector(`[name="active_cb"][data-court-id="${court._id}"]`); + if (active_checkbox) { + active_checkbox.checked = court.is_active; + } + const umpire_checkbox = courts_table.querySelector(`[name="umpire_cb"][data-court-id="${court._id}"]`); + if (umpire_checkbox) { + umpire_checkbox.checked = court.has_umpire !== false; + } + const service_judge_checkbox = courts_table.querySelector(`[name="service_judge_cb"][data-court-id="${court._id}"]`); + if (service_judge_checkbox) { + service_judge_checkbox.checked = court.has_service_judge !== false; + } + apply_court_official_checkbox_dependencies(court); + break; + default: + cmatch.update_court(court); + break; + } + } + + function create_checkbox(curt, parent_el, filed_id, label_class) { + const label = uiu.el(parent_el, 'label', label_class); + const attrs = { + type: 'checkbox', + name: filed_id, + }; + if (curt[filed_id]) { + attrs.checked = 'checked'; + } + const result = uiu.el(label, 'input', attrs); + uiu.el(label, 'span', {}, ci18n('tournament:edit:' + filed_id)); + bind_live_prop(result, filed_id); + return result; + } + + function create_input(curt, type, parent_el, filed_id) { + const text_input = uiu.el(parent_el, 'label'); + uiu.el(text_input, 'span', {}, ci18n('tournament:edit:' + filed_id)); + const result = uiu.el(text_input, 'input', { + type: type, + name: filed_id, + value: curt[filed_id] || '', + }); + bind_live_prop(result, filed_id, { + event_name: (type === 'text') ? 'blur' : 'change', + get_value: type === 'number' ? input_el => Number(input_el.value) : undefined, + }); + return result; + } + + function create_undecorated_input(type, parent_el, filed_id) { + return ( + uiu.el(parent_el, 'input', { + type: type, + name: filed_id, + id: filed_id, + value: '', + }) + ); + } + + function create_textarea_input(type, parent_el, filed_id) { + return ( + uiu.el(parent_el, 'textarea', { + type: type, + name: filed_id, + id: filed_id, + value: '', + }) + ); + } + + function create_numeric_input(curt, parent_el, filed_id, min_value, max_value, default_value, step_value) { + const text_input = uiu.el(parent_el, 'label'); + uiu.el(text_input, 'span', {}, ci18n('tournament:edit:' + filed_id)); + const result = uiu.el(text_input, 'input', { + type: "number", + name: filed_id, + value: curt[filed_id] || default_value, + min: min_value, + max: max_value, + step: step_value + }); + bind_live_prop(result, filed_id, { + get_value: input_el => Number(input_el.value), + }); + return result; + } + + function create_select_input(curt, parent_el, filed_id, values) { + const label = uiu.el(parent_el, 'label'); + uiu.el(label, 'span', {}, ci18n('tournament:edit:' + filed_id)); + const result = uiu.el(label, 'select', { + name: filed_id, + }); + const current_value = curt[filed_id] == null ? 'none' : String(curt[filed_id]); + for (const value of values) { + const value_str = String(value); + const attrs = { value: value_str }; + if (current_value === value_str) { + attrs.selected = 'selected'; + } + uiu.el(result, 'option', attrs, ci18n(`tournament:edit:option:${filed_id}:${value_str}`)); + } + bind_live_prop(result, filed_id, { + get_value: input_el => input_el.value === 'none' ? 'none' : Number(input_el.value), + }); + return result; + } + + function create_rule_select_input(curt, parent_el, filed_id, values, fallback_value_fn) { + const box = uiu.el(parent_el, 'fieldset', 'automation_rule_box'); + const legend = uiu.el(box, 'legend'); + uiu.el(legend, 'span', {}, ci18n('tournament:edit:' + filed_id)); + const value_label = uiu.el(box, 'label', 'automation_rule_value'); + uiu.el(value_label, 'span', {}, ci18n('tournament:edit:' + filed_id + ':value')); + const result = uiu.el(value_label, 'select', { + name: filed_id, + }); + const fallback_value = typeof fallback_value_fn === 'function' ? fallback_value_fn() : 'disabled'; + const current_value = curt[filed_id] == null ? String(fallback_value) : String(curt[filed_id]); + for (const value of values) { + const attrs = { value }; + if (current_value === value) { + attrs.selected = 'selected'; + } + uiu.el(result, 'option', attrs, ci18n(`tournament:edit:option:${filed_id}:${value}`)); + } + bind_live_prop(result, filed_id, { + get_value: input_el => input_el.value, + }); + result.rule_box = box; + return result; + } + + function create_rule_limit_input(curt, parent_el, enabled_field_id, value_field_id, default_value, min_value, max_value, step_value, unit_label_key) { + const value_is_set = curt[value_field_id] != null && curt[value_field_id] !== 'none' && curt[value_field_id] !== ''; + const enabled_value = curt[enabled_field_id] != null ? !!curt[enabled_field_id] : value_is_set; + const numeric_value = value_is_set ? Number(curt[value_field_id]) : default_value; + + const box = uiu.el(parent_el, 'fieldset', 'automation_rule_box'); + const legend = uiu.el(box, 'legend'); + const enabled_input = uiu.el(legend, 'input', { + type: 'checkbox', + name: enabled_field_id, + }); + if (enabled_value) { + enabled_input.checked = true; + } + uiu.el(legend, 'span', {}, ci18n('tournament:edit:' + enabled_field_id)); + + const value_label = uiu.el(box, 'label', 'automation_rule_value'); + uiu.el(value_label, 'span', {}, ci18n('tournament:edit:' + value_field_id)); + const value_input = uiu.el(value_label, 'input', { + type: 'number', + name: value_field_id, + value: Number.isFinite(numeric_value) ? numeric_value : default_value, + min: min_value, + max: max_value, + step: step_value, + }); + if (unit_label_key) { + uiu.el(value_label, 'span', 'automation_rule_unit', ci18n(unit_label_key)); + } + + const sync_disabled = () => { + value_input.disabled = !enabled_input.checked; + }; + sync_disabled(); + enabled_input.addEventListener('change', sync_disabled); + + bind_live_prop(enabled_input, enabled_field_id); + bind_live_prop(value_input, value_field_id, { + get_value: input_el => Number(input_el.value), + }); + + return { + box, + enabled_input, + value_input, + }; + } + + function create_duration_seconds_input(curt, parent_el, filed_id, min_seconds, max_seconds, default_seconds, step_seconds) { + const text_input = uiu.el(parent_el, 'label'); + uiu.el(text_input, 'span', {}, ci18n('tournament:edit:' + filed_id)); + const current_ms = Number(curt[filed_id]); + const value_seconds = Number.isFinite(current_ms) && current_ms > 0 ? (current_ms / 1000) : default_seconds; + const result = uiu.el(text_input, 'input', { + type: "number", + name: filed_id, + value: value_seconds, + min: min_seconds, + max: max_seconds, + step: step_seconds + }); + bind_live_prop(result, filed_id, { + get_value: input_el => Number(input_el.value) * 1000, + }); + return result; + } + + function createCourtSelectBox(parentEl, parent_id, court_id) { + const court_select_box = uiu.el(parentEl, 'select', { + name: 'court_' + parent_id, + }); + + const empty_id = "--"; + const attrs = { + 'data-display-setting-id': court_id, + value: empty_id, + } + + if (!court_id || empty_id === court_id) { + attrs.selected = 'selected'; + } + uiu.el(court_select_box, 'option', attrs, empty_id); + + for (const court of curt.courts) { + const attrs = { + 'data-display-setting-id': court_id, + value: court._id, + } + + if ((court_id === court._id)) { + attrs.selected = 'selected'; + } + uiu.el(court_select_box, 'option', attrs, court.num); + } + + + court_select_box.addEventListener('change', (e) => { + const select_box = e.target; + const display_setting_id = select_box.name.split("_")[1]; + send_with_live_status({ + type: 'relocate_display', + tournament_key: curt.key, + new_court_id: e.srcElement.value, + display_setting_id: display_setting_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function createDisplaySettingsSelectBox(parentEl, parent_id, displaysettings_id) { + const displaysettings_select_box = uiu.el(parentEl, 'select', { + name: 'displaysettings_' + parent_id, + }); + + createSelectBoxContent(displaysettings_select_box, curt.displaysettings, displaysettings_id); + + displaysettings_select_box.addEventListener('change', (e) => { + const select_box = e.target; + const display_setting_id = select_box.name.split("_")[1]; + send_with_live_status({ + type: 'change_display_mode', + tournament_key: curt.key, + new_displaysettings_id: e.srcElement.value, + display_setting_id: display_setting_id, + }, err => { + if (err) { + return cerror.net(err); + } + }); + }); + } + + function createGeneralDisplaySettingsSelectBox(parentEl, displaysettings_id) { + const displaysettings_select_box = uiu.el(parentEl, 'select', { + name: 'displaysettings_general' + }); + createSelectBoxContent(displaysettings_select_box, curt.displaysettings, displaysettings_id); + return displaysettings_select_box; + } + function createSelectBoxContent(select_box, content, selected_id) { + for (const item of content) { + const attrs = { + 'data-display-setting-id': selected_id, + value: item.id, + label: item.description, + } + if ((selected_id === item.id)) { + attrs.selected = 'selected'; + } + uiu.el(select_box, 'option', attrs, item.id); + } + } + + function render_upcoming(container) { + cmatch.prepare_render(curt); + const courts_container = uiu.el(container, 'div', 'courts_container'); + cmatch.render_courts(courts_container, 'public'); + const upcoming_container = uiu.el(container, 'div', 'upcoming_container'); + cmatch.render_upcoming_matches(upcoming_container); + } + + function render_current_matches(container) { + cmatch.prepare_render(curt); + const courts_container = uiu.el(container, 'div', 'courts_container'); + cmatch.render_courts(courts_container, 'public'); + } + + function render_next_matches(container) { + cmatch.prepare_render(curt); + const upcoming_container = uiu.el(container, 'div', 'upcoming_container'); + cmatch.render_upcoming_matches(upcoming_container); + } + + function get_location_name_filter() { + const params = new URLSearchParams(window.location.search); + return params.get('location'); + } + + function match_matches_selected_location(match) { + const param_location = get_location_name_filter(); + if (!param_location) { + return true; + } + + const loc = utils.find(curt.locations, l => l._id === match.setup.location_id); + if (!loc) { + return true; + } + return loc.name === param_location; + } + + function person_display_name(person) { + if (!person) { + return ''; + } + if (person.name) { + return person.name; + } + const surname = person.lastname || person.surname || ''; + return [person.firstname, surname].filter(Boolean).join(' '); + } + + function split_person_name_parts(person) { + const firstname = (person && person.firstname ? person.firstname : '').trim(); + const surname = ((person && (person.lastname || person.surname)) ? (person.lastname || person.surname) : '').trim(); + if (firstname || surname) { + return { + first_parts: firstname ? firstname.split(/\s+/).filter(Boolean) : [], + surname, + }; + } + + const fallback = person_display_name(person).trim(); + if (!fallback) { + return { first_parts: [], surname: '' }; + } + const parts = fallback.split(/\s+/).filter(Boolean); + if (parts.length <= 1) { + return { first_parts: parts, surname: '' }; + } + return { + first_parts: parts.slice(0, -1), + surname: parts[parts.length - 1], + }; + } + + function build_person_name_variants(person) { + const { first_parts, surname } = split_person_name_parts(person); + if (first_parts.length === 0) { + return [person_display_name(person)]; } - curt = response.tournament; - if (curt.language && curt.language !== 'auto') { - ci18n.switch_language(curt.language); - } - uiu.text_qs('.btp_status', 'BTP status: ' + curt.btp_status); - uiu.text_qs('.ticker_status', 'Ticker status: ' + curt.ticker_status); - success_cb(); - }); -} + const variants = []; + const push_variant = (first_name_parts) => { + const label = [...first_name_parts, surname].filter(Boolean).join(' ').trim(); + if (label && !variants.includes(label)) { + variants.push(label); + } + }; -function ui_create() { - const main = uiu.qs('.main'); - - uiu.empty(main); - const form = uiu.el(main, 'form'); - uiu.el(form, 'h2', {}, ci18n('Create tournament')); - const id_label = uiu.el(form, 'label', {}, ci18n('create:id:label')); - const key_input = uiu.el(id_label, 'input', { - type: 'text', - name: 'key', - autofocus: 'autofocus', - required: 'required', - pattern: '^[a-z0-9]+$', - }); - uiu.el(form, 'button', { - role: 'submit', - }, ci18n('Create tournament')); - key_input.focus(); + push_variant(first_parts); - form_utils.onsubmit(form, function(data) { - send({ - type: 'create_tournament', - key: data.key, - }, function(err) { - if (err) return cerror.net(err); + if (first_parts.length > 1) { + push_variant([ + first_parts[0], + ...first_parts.slice(1).map(name => name[0] + '.'), + ]); + push_variant([first_parts[0]]); + } - uiu.remove(form); - switch_tournament(data.key, ui_show); - }); - }); -} + push_variant([first_parts[0][0] + '.']); + return variants; + } -function ui_list() { - crouting.set('t/'); - toprow.set([{ - label: ci18n('Tournaments'), - func: ui_list, - }]); + function get_self_check_in_matches() { + return curt.matches + .filter(m => m.setup && m.setup.state === 'preparation' && m.setup.is_match && match_matches_selected_location(m)) + .sort((a, b) => (a.setup.preparation_call_timestamp || 0) - (b.setup.preparation_call_timestamp || 0)); + } - send({ - type: 'tournament_list', - }, function(err, response) { - if (err) { - return cerror.net(err); + function self_check_in_match_structure_signature(match) { + if (!match || !match.setup || !match.setup.is_match || match.setup.state !== 'preparation' || !match_matches_selected_location(match)) { + return null; } - list_show(response.tournaments); - }); -} -crouting.register(/^t\/$/, ui_list, change.default_handler); -function list_show(tournaments) { - const main = uiu.qs('.main'); - uiu.empty(main); - uiu.el(main, 'h1', {}, 'Tournaments'); - tournaments.forEach(function(t) { - const link = uiu.el(main, 'div', 'vlink', t.name || t.key); - link.addEventListener('click', function() { - switch_tournament(t.key, ui_show); + return JSON.stringify({ + match_id: match._id, + match_num: match.setup.match_num, + event_name: match.setup.event_name, + scheduled_time_str: match.setup.scheduled_time_str, + location_id: match.setup.location_id, + participants: self_check_in_participants(match).map(participant => ({ + key: participant.key, + label: participant.label, + role_label: participant.role_label || '', + })), }); - }); + } - const create_btn = uiu.el(main, 'button', { - role: 'button', - }, 'Create tournament ...'); - create_btn.addEventListener('click', ui_create); -} + function self_check_in_match_status_signature(match) { + if (!match || !match.setup || !match.setup.is_match || match.setup.state !== 'preparation' || !match_matches_selected_location(match)) { + return null; + } + return JSON.stringify({ + match_id: match._id, + participants: self_check_in_participants(match).map((participant) => ({ + key: participant.key, + checked_in: participant.checked_in, + })), + }); + } + + function update_self_check_in_match_status(match_id) { + const list = document.querySelector('.self_check_in_list'); + const match = utils.find(curt.matches, m => m._id === match_id); + if (!list || !match) { + _update_all_ui_elements_self_check_in(); + return; + } + const card = list.querySelector('.self_check_in_match[data-match-id="' + match_id + '"]'); + if (!card) { + _update_all_ui_elements_self_check_in(); + return; + } -function update_score(c) { - const cval = c.val; - const match_id = cval.match_id; + const participants = self_check_in_participants(match); + const chips = Array.from(card.querySelectorAll('.self_check_in_chip')); + if (chips.length !== participants.length) { + update_self_check_in_match_card(match_id); + return; + } - // Find the match - const m = utils.find(curt.matches, m => m._id === match_id); - if (!m) { - cerror.silent('Cannot find match to update score, ID: ' + JSON.stringify(match_id)); - return; + const chips_by_key = new Map( + chips.map((chip) => [chip.getAttribute('data-participant-key'), chip]) + ); + for (const participant of participants) { + const chip = chips_by_key.get(participant.key); + if (!chip) { + update_self_check_in_match_card(match_id); + return; + } + chip.classList.toggle('self_check_in_chip_ready', !!participant.checked_in); + chip.classList.toggle('self_check_in_chip_waiting', !participant.checked_in); + } + + const all_ready = participants.length > 0 && participants.every((participant) => participant.checked_in); + card.classList.toggle('self_check_in_match_ready', all_ready); + card.classList.toggle('self_check_in_match_waiting', !all_ready); + const status_el = card.querySelector('.self_check_in_match_status'); + if (status_el) { + status_el.textContent = ci18n(all_ready ? 'Self-Check-In: ready' : 'Self-Check-In: waiting'); + } } - const old_section = cmatch.calc_section(m); - m.network_score = cval.network_score; - m.presses = cval.presses; - m.team1_won = cval.team1_won; - m.shuttle_count = cval.shuttle_count; - const new_section = cmatch.calc_section(m); + function rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, match_id) { + const container = document.querySelector('.self_check_in_container'); + const match = utils.find(curt.matches, m => m._id === match_id); + const after_structure_signature = self_check_in_match_structure_signature(match); + const after_status_signature = self_check_in_match_status_signature(match); + if (before_structure_signature !== after_structure_signature) { + if (!container || !after_structure_signature) { + _update_all_ui_elements_self_check_in(); + return; + } + const visible_before_ids = Array.from(container.querySelectorAll('.self_check_in_match')).map((card) => String(card.getAttribute('data-match-id'))); + const visible_after_ids = get_self_check_in_matches().map((m) => String(m._id)); + if ( + visible_before_ids.length !== visible_after_ids.length || + visible_before_ids.some((id, index) => id !== visible_after_ids[index]) + ) { + _update_all_ui_elements_self_check_in(); + return; + } + update_self_check_in_match_card(match_id); + return; + } - if (old_section === new_section) { - cmatch.update_match_score(m); - } else { - _show_render_matches(); + if (before_status_signature !== after_status_signature) { + update_self_check_in_match_status(match_id); + } } -} -function update_current_match(c) { - change.change_current_match(c.val); - _show_render_matches(); -} + function self_check_in_participants(match) { + const participants = []; + match.setup.teams.forEach((team, team_index) => { + team.players.forEach((player, player_index) => { + participants.push({ + key: 'player_' + team_index + '_' + player_index + '_' + player.btp_id, + role: 'player', + match_id: match._id, + participant_id: player.btp_id, + label: person_display_name(player), + label_variants: build_person_name_variants(player), + checked_in: !!player.checked_in, + }); + }); + }); -function _show_render_matches() { - cmatch.render_courts(uiu.qs('.courts_container')); - cmatch.render_unassigned(uiu.qs('.unassigned_container')); - cmatch.render_finished(uiu.qs('.finished_container')); -} + if (match.setup.umpire && match.setup.umpire.btp_id != null) { + participants.push({ + key: 'umpire_' + match.setup.umpire.btp_id, + role: 'umpire', + match_id: match._id, + participant_id: match.setup.umpire.btp_id, + label: person_display_name(match.setup.umpire), + label_variants: build_person_name_variants(match.setup.umpire), + checked_in: !!match.setup.umpire.checked_in, + role_label: ci18n('Umpire'), + }); + } -function ui_btp_fetch() { - send({ - type: 'btp_fetch', - tournament_key: curt.key, - }, err => { - if (err) { - return cerror.net(err); + if (match.setup.service_judge && match.setup.service_judge.btp_id != null) { + participants.push({ + key: 'service_judge_' + match.setup.service_judge.btp_id, + role: 'service_judge', + match_id: match._id, + participant_id: match.setup.service_judge.btp_id, + label: person_display_name(match.setup.service_judge), + label_variants: build_person_name_variants(match.setup.service_judge), + checked_in: !!match.setup.service_judge.checked_in, + role_label: ci18n('Service judge'), + }); } - }); -} -function ui_ticker_push() { - send({ - type: 'ticker_reset', - tournament_key: curt.key, - }, err => { - if (err) { - return cerror.net(err); + if (Array.isArray(match.setup.tabletoperators)) { + match.setup.tabletoperators.forEach((operator, index) => { + participants.push({ + key: 'tabletoperator_' + index + '_' + operator.btp_id, + role: 'tabletoperator', + match_id: match._id, + participant_id: operator.btp_id, + label: person_display_name(operator), + label_variants: build_person_name_variants(operator), + checked_in: !!operator.checked_in, + role_label: ci18n('Tablet operator'), + }); + }); } - }); -} -function ui_show() { - crouting.set('t/:key/', {key: curt.key}); - const bup_lang = ((curt.language && curt.language !== 'auto') ? '&lang=' + encodeURIComponent(curt.language) : ''); - const bup_dm_style = '&dm_style=' + encodeURIComponent(curt.dm_style || 'international'); - toprow.set([{ - label: ci18n('Tournaments'), - func: ui_list, - }, { - label: curt.name || curt.key, - func: ui_show, - 'class': 'ct_name', - }], [{ - label: 'Scoreboard', - href: '/bup/#btsh_e=' + encodeURIComponent(curt.key) + '&display' + bup_dm_style + bup_lang, - }, { - label: 'Umpire Panel', - href: '/bup/#btsh_e=' + encodeURIComponent(curt.key) + bup_lang, - }, { - label: ci18n('Next Matches'), - href: '/admin/t/' + encodeURIComponent(curt.key) + '/upcoming', - },]); - - const main = uiu.qs('.main'); - uiu.empty(main); - - const settings_btn = uiu.el(main, 'div', 'tournament_settings_link vlink', ci18n('edit tournament')); - settings_btn.addEventListener('click', ui_edit); - - if (curt.btp_enabled) { - const btp_fetch_btn = uiu.el(main, 'button', 'tournament_btp_fetch', ci18n('update from BTP')); - btp_fetch_btn.addEventListener('click', ui_btp_fetch); - } - if (curt.ticker_enabled) { - const ticker_push_btn = uiu.el(main, 'button', 'tournament_ticker_push', ci18n('update ticker')); - ticker_push_btn.addEventListener('click', ui_ticker_push); - } - - uiu.el(main, 'h1', 'tournament_name ct_name', curt.name || curt.key); - - cmatch.prepare_render(curt); - - uiu.el(main, 'div', 'courts_container'); - uiu.el(main, 'div', 'unassigned_container'); - const match_create_container = uiu.el(main, 'div'); - cmatch.render_create(match_create_container); - uiu.el(main, 'div', 'finished_container'); - _show_render_matches(); - - const footer_links = uiu.el(main, 'div', 'footer_links'); - const umpires_link = uiu.el(footer_links, 'span', 'vlink', ci18n('umpires:status:heading')); - umpires_link.addEventListener('click', cumpires.ui_status); - - if (/^dmo35/.test(curt.key)) { - const csvexport_link = uiu.el(footer_links, 'span', 'vlink', ci18n('csvexport:winners')); - csvexport_link.addEventListener('click', ccsvexport.export_winners); - } - - if (curt.is_nation_competition) { - crouting.render_link(footer_links, `t/${curt.key}/nationstats`, ci18n('nationstats')); + return participants; } -} -_route_single(/t\/([a-z0-9]+)\/$/, ui_show, change.default_handler(_show_render_matches, { - score: update_score, - court_current_match: update_current_match, -})); - -function _upload_logo(e) { - const input = e.target; - if (!input.files.length) return; - - const reader = new FileReader(); - reader.readAsDataURL(input.files[0]); - reader.onload = () => { - send({ - type: 'tournament_upload_logo', - tournament_key: curt.key, - data_url: reader.result, - }, (err) => { - if (err) { - return cerror.net(err); + + function resolve_self_check_in_court_label(match) { + const current_match = utils.find(curt.matches, (m) => m._id === match._id) || match; + const court_id = (match.setup && match.setup.court_id) || (current_match.setup && current_match.setup.court_id); + let court = null; + if (court_id && curt.courts_by_id && curt.courts_by_id[court_id]) { + court = curt.courts_by_id[court_id]; + } + if (!court && Array.isArray(curt.courts)) { + court = curt.courts.find((c) => c._id === court_id) + || curt.courts.find((c) => c.match_id === match._id) + || curt.courts.find((c) => current_match && c.match_id === current_match._id) + || curt.courts.find((c) => match.btp_id && c.match_id === ('btp_' + match.btp_id)) + || curt.courts.find((c) => current_match && current_match.btp_id && c.match_id === ('btp_' + current_match.btp_id)); + } + if (!court || !court.num) { + return ''; + } + return ci18n('Court') + ' ' + court.num; + } + + function render_self_check_in_chip(container, participant) { + const attrs = { + type: 'button', + 'class': 'self_check_in_chip self_check_in_chip_' + (participant.checked_in ? 'ready' : 'waiting') + (participant.role_label ? ' self_check_in_chip_with_role' : ''), + 'data-role': participant.role, + 'data-match_id': participant.match_id, + 'data-participant_id': participant.participant_id, + 'data-participant-key': participant.key, + }; + const chip = uiu.el(container, 'button', attrs); + if (participant.role_label) { + uiu.el(chip, 'span', 'self_check_in_chip_role', participant.role_label); + } + const cached_fit = self_check_in_chip_fit_cache[participant.key]; + const initial_label = cached_fit ? cached_fit.label : participant.label; + const name_el = uiu.el(chip, 'span', 'self_check_in_chip_name', initial_label); + chip._name_el = name_el; + chip._label_variants = participant.label_variants || [participant.label]; + chip._participant_key = participant.key; + if (cached_fit && cached_fit.font_size) { + name_el.style.fontSize = cached_fit.font_size; + } + chip.addEventListener('click', function(ev) { + ev.stopPropagation(); + const checked_in = !chip.classList.contains('self_check_in_chip_ready'); + const payload = { + tournament_key: curt.key, + match_id: participant.match_id, + checked_in, + }; + + if (participant.role === 'player') { + payload.type = 'match_player_check_in'; + payload.player_id = participant.participant_id; + } else { + payload.type = 'match_participant_check_in'; + payload.role = participant.role; + payload.participant_id = participant.participant_id; } - input.closest('form').reset(); + + send(payload, function(err) { + if (err) { + return cerror.net(err); + } + }); }); - }; - reader.onerror = (e) => { - alert('Failed to upload: ' + e); - }; -} + } -function ui_edit() { - crouting.set('t/:key/edit', {key: curt.key}); - toprow.set([{ - label: ci18n('Tournaments'), - func: ui_list, - }, { - label: curt.name || curt.key, - func: ui_show, - 'class': 'ct_name', - }, { - label: ci18n('edit tournament'), - func: ui_edit, - }]); - - const main = uiu.qs('.main'); - uiu.empty(main); - - const form = uiu.el(main, 'form', 'tournament_settings'); - const key_label = uiu.el(form, 'label'); - uiu.el(key_label, 'span', {}, ci18n('tournament:edit:id')); - uiu.el(key_label, 'input', { - type: 'text', - name: 'key', - readonly: 'readonly', - disabled: 'disabled', - title: 'Can not be changed', - 'class': 'uneditable', - value: curt.key, - }); + function fit_self_check_in_chip(chip) { + const name_el = chip._name_el; + const variants = chip._label_variants || [name_el.textContent]; + if (!name_el.dataset.baseFontSize) { + const previous_font_size = name_el.style.fontSize; + name_el.style.fontSize = ''; + name_el.dataset.baseFontSize = String(parseFloat(window.getComputedStyle(name_el).fontSize)); + name_el.style.fontSize = previous_font_size; + } + const chip_style = window.getComputedStyle(chip); + const css_base_font_size = Number(name_el.dataset.baseFontSize) || 16; + const role_el = chip.querySelector('.self_check_in_chip_role'); + const available_height = chip.clientHeight + - parseFloat(chip_style.paddingTop || 0) + - parseFloat(chip_style.paddingBottom || 0) + - (role_el ? role_el.offsetHeight + parseFloat(chip_style.gap || 0) : 0); + const height_based_font_size = Math.max( + css_base_font_size, + available_height * (role_el ? 0.58 : 0.7) + ); + const base_font_size = height_based_font_size; + const available_width = chip.clientWidth + - parseFloat(chip_style.paddingLeft || 0) + - parseFloat(chip_style.paddingRight || 0); + const measure_text_width = (text, font_size_px) => { + if (!self_check_in_measure_probe) { + self_check_in_measure_probe = document.createElement('span'); + self_check_in_measure_probe.style.position = 'absolute'; + self_check_in_measure_probe.style.visibility = 'hidden'; + self_check_in_measure_probe.style.pointerEvents = 'none'; + self_check_in_measure_probe.style.whiteSpace = 'nowrap'; + self_check_in_measure_probe.style.left = '-99999px'; + self_check_in_measure_probe.style.top = '0'; + document.body.appendChild(self_check_in_measure_probe); + } + const probe = self_check_in_measure_probe; + probe.textContent = text; + const name_style = window.getComputedStyle(name_el); + probe.style.fontFamily = name_style.fontFamily; + probe.style.fontWeight = name_style.fontWeight; + probe.style.fontStyle = name_style.fontStyle; + probe.style.fontStretch = name_style.fontStretch; + probe.style.fontVariant = name_style.fontVariant; + probe.style.fontSize = font_size_px + 'px'; + probe.style.lineHeight = name_style.lineHeight; + probe.style.letterSpacing = window.getComputedStyle(name_el).letterSpacing; + const width = probe.getBoundingClientRect().width; + return width; + }; + const min_font_size = Math.max(base_font_size * 0.18, 8); + const line_height_factor = 1.2; + const max_font_by_height = Math.max(min_font_size, (available_height / line_height_factor) * 0.98); + const layout_fit_cache_key = JSON.stringify({ + variants, + width: Math.round(available_width), + height: Math.round(available_height), + base_font_size: Math.round(base_font_size * 10) / 10, + min_font_size: Math.round(min_font_size * 10) / 10, + max_font_by_height: Math.round(max_font_by_height * 10) / 10, + }); + const cached_layout_fit = self_check_in_layout_fit_cache.get(layout_fit_cache_key); + if (cached_layout_fit) { + name_el.textContent = cached_layout_fit.label; + name_el.style.fontSize = cached_layout_fit.font_size; + self_check_in_chip_fit_cache[chip._participant_key] = cached_layout_fit; + return; + } + let best = null; - const name_label = uiu.el(form, 'label'); - uiu.el(name_label, 'span', {}, ci18n('tournament:edit:name')); - uiu.el(name_label, 'input', { - type: 'text', - name: 'name', - required: 'required', - value: curt.name || curt.key, - 'class': 'ct_name', - }); + for (const variant of variants) { + const width_at_base = measure_text_width(variant, base_font_size); + const width_ratio = available_width / Math.max(width_at_base, 1); + const width_limited_font_size = base_font_size * width_ratio * 0.98; + const fitted_font_size = Math.max( + min_font_size, + Math.min(base_font_size, max_font_by_height, width_limited_font_size) + ); - // Tournament language selection - const language_label = uiu.el(form, 'label'); - uiu.el(language_label, 'span', {}, ci18n('tournament:edit:language')); - const language_select = uiu.el(language_label, 'select', { - name: 'language', - required: 'required', - }); - const all_langs = ci18n.get_all_languages(); - uiu.el(language_select, 'option', {value: 'auto'}, ci18n('tournament:edit:language:auto')); - for (const l of all_langs) { - const l_attrs = { - value: l._code, + if (!best || fitted_font_size > best.font_size + 0.05) { + best = { + label: variant, + font_size: fitted_font_size, + }; + } + } + + const chosen = best || { + label: variants[variants.length - 1], + font_size: min_font_size, + }; + name_el.textContent = chosen.label; + name_el.style.fontSize = chosen.font_size + 'px'; + const chosen_fit = { + label: chosen.label, + font_size: name_el.style.fontSize, }; - if (l._code === curt.language) { - l_attrs.selected = 'selected'; + self_check_in_layout_fit_cache.set(layout_fit_cache_key, chosen_fit); + self_check_in_chip_fit_cache[chip._participant_key] = chosen_fit; + } + + function fit_self_check_in_card(card) { + const card_height = card.clientHeight || 0; + if (card_height > 0) { + const header_height = Math.max(56, Math.min(card_height * 0.26, 140)); + card.style.setProperty('--self-check-in-header-height', header_height + 'px'); + card.style.setProperty('--self-check-in-header-gap', Math.max(4, Math.min(header_height * 0.06, 12)) + 'px'); + card.style.setProperty('--self-check-in-number-font-size', Math.max(18, Math.min(header_height * 0.18, 34)) + 'px'); + card.style.setProperty('--self-check-in-event-font-size', Math.max(26, Math.min(header_height * 0.3, 54)) + 'px'); + card.style.setProperty('--self-check-in-meta-font-size', Math.max(18, Math.min(header_height * 0.18, 34)) + 'px'); + card.style.setProperty('--self-check-in-status-font-size', Math.max(18, Math.min(header_height * 0.18, 34)) + 'px'); + const heading = card.querySelector('.self_check_in_match_heading'); + const header_gap = Math.max(4, Math.min(header_height * 0.06, 12)); + if (heading) { + const available_header_height = Math.max(24, header_height - header_gap); + if (heading.scrollHeight > available_header_height + 1) { + const ratio = available_header_height / Math.max(heading.scrollHeight, 1); + card.style.setProperty('--self-check-in-number-font-size', Math.max(14, Math.min(header_height * 0.18 * ratio * 0.98, 34)) + 'px'); + card.style.setProperty('--self-check-in-event-font-size', Math.max(18, Math.min(header_height * 0.3 * ratio * 0.98, 54)) + 'px'); + card.style.setProperty('--self-check-in-meta-font-size', Math.max(14, Math.min(header_height * 0.18 * ratio * 0.98, 34)) + 'px'); + card.style.setProperty('--self-check-in-status-font-size', Math.max(14, Math.min(header_height * 0.18 * ratio * 0.98, 34)) + 'px'); + } + } } - uiu.el(language_select, 'option', l_attrs, l._name); + card.querySelectorAll('.self_check_in_chip').forEach(fit_self_check_in_chip); + card.style.visibility = 'visible'; } - // Team competition? - const is_team_label = uiu.el(form, 'label'); - const is_team_attrs = { - type: 'checkbox', - name: 'is_team', - }; - if (curt.is_team) { - is_team_attrs.checked = 'checked'; + function update_self_check_in_match_card(match_id) { + const list = document.querySelector('.self_check_in_list'); + const match = utils.find(curt.matches, m => m._id === match_id); + if (!list || !match) { + _update_all_ui_elements_self_check_in(); + return; + } + const current_card = list.querySelector('.self_check_in_match[data-match-id="' + match_id + '"]'); + if (!current_card) { + _update_all_ui_elements_self_check_in(); + return; + } + const temp = document.createElement('div'); + render_self_check_in_match_card(temp, match, false); + const new_card = temp.firstElementChild; + if (!new_card) { + _update_all_ui_elements_self_check_in(); + return; + } + [ + '--self-check-in-header-height', + '--self-check-in-header-gap', + '--self-check-in-number-font-size', + '--self-check-in-event-font-size', + '--self-check-in-meta-font-size', + '--self-check-in-status-font-size', + ].forEach((prop) => { + const value = current_card.style.getPropertyValue(prop); + if (value) { + new_card.style.setProperty(prop, value); + } + }); + current_card.replaceWith(new_card); + schedule_fit_self_check_in_cards(new_card); } - uiu.el(is_team_label, 'input', is_team_attrs); - uiu.el(is_team_label, 'span', {}, ci18n('team competition')); - // Nation competition? - const is_nation_competition_label = uiu.el(form, 'label'); - const is_nation_competition_attrs = { - type: 'checkbox', - name: 'is_nation_competition', - }; - if (curt.is_nation_competition) { - is_nation_competition_attrs.checked = 'checked'; - } - uiu.el(is_nation_competition_label, 'input', is_nation_competition_attrs); - uiu.el(is_nation_competition_label, 'span', {}, ci18n('nation competition')); - - // Default display - const cur_dm_style = curt.dm_style || 'international'; - const dm_style_label = uiu.el(form, 'label'); - uiu.el(dm_style_label, 'span', {}, ci18n('tournament:edit:dm_style')); - const dm_style_select = uiu.el(dm_style_label, 'select', { - name: 'dm_style', - required: 'required', - }); - const all_dm_styles = displaymode.ALL_STYLES; - for (const s of all_dm_styles) { - const s_attrs = { - value: s, - }; - if (s === cur_dm_style) { - s_attrs.selected = 'selected'; + function render_self_check_in_match_card(container, match, do_fit) { + if (do_fit === undefined) { + do_fit = true; + } + const participants = self_check_in_participants(match); + const all_ready = participants.length > 0 && participants.every(p => p.checked_in); + const columns = participants.length <= 3 ? 1 : 2; + const rows = Math.ceil(participants.length / columns); + const has_officials = participants.some((participant) => !!participant.role_label); + const card = uiu.el(container, 'section', { + 'class': 'self_check_in_match ' + (all_ready ? 'self_check_in_match_ready' : 'self_check_in_match_waiting'), + }); + card.setAttribute('data-match-id', String(match._id)); + card.setAttribute('data-rows', String(rows)); + card.style.visibility = 'hidden'; + + const heading = uiu.el(card, 'div', 'self_check_in_match_heading'); + const left = uiu.el(heading, 'div', 'self_check_in_match_heading_left'); + const top_row = uiu.el(left, 'div', 'self_check_in_match_top_row'); + uiu.el(top_row, 'div', 'self_check_in_match_number', '#' + match.setup.match_num); + uiu.el(top_row, 'div', 'self_check_in_match_status', ci18n(all_ready ? 'Self-Check-In: ready' : 'Self-Check-In: waiting')); + const event_row = uiu.el(left, 'div', 'self_check_in_match_event_row'); + uiu.el(event_row, 'div', 'self_check_in_match_event', match.setup.event_name || ''); + uiu.el(event_row, 'div', 'self_check_in_match_court', resolve_self_check_in_court_label(match)); + + const meta = []; + if (match.setup.scheduled_time_str) { + meta.push(match.setup.scheduled_time_str); + } + if (match.setup.location_id) { + const loc = utils.find(curt.locations, l => l._id === match.setup.location_id); + if (loc && loc.name) { + meta.push(loc.name); + } + } + if (meta.length > 0) { + uiu.el(left, 'div', 'self_check_in_match_meta', meta.join(' • ')); + } + + const chips = uiu.el(card, 'div', 'self_check_in_chips'); + chips.setAttribute('data-columns', String(columns)); + chips.setAttribute('data-rows', String(rows)); + chips.setAttribute('data-has-officials', has_officials ? '1' : '0'); + if (rows === 3) { + chips.style.setProperty('--self-check-in-chip-name-scale-row', '1.12'); + chips.style.setProperty('--self-check-in-chip-box-scale-row', '1.04'); + } else if (rows === 2) { + chips.style.setProperty('--self-check-in-chip-name-scale-row', '0.80'); + chips.style.setProperty('--self-check-in-chip-box-scale-row', '0.98'); + } else { + chips.style.setProperty('--self-check-in-chip-name-scale-row', '1'); + chips.style.setProperty('--self-check-in-chip-box-scale-row', '1'); + } + participants.forEach((participant) => render_self_check_in_chip(chips, participant)); + if (do_fit) { + schedule_fit_self_check_in_cards(card); } - uiu.el(dm_style_select, 'option', s_attrs, s); } - // Placed on court required? - const only_now_on_court_label = uiu.el(form, 'label'); - const attrs = { - type: 'checkbox', - name: 'only_now_on_court', - }; - if (curt.only_now_on_court) { - attrs.checked = 'checked'; - } - uiu.el(only_now_on_court_label, 'input', attrs); - uiu.el(only_now_on_court_label, 'span', {}, ci18n('tournament:edit:only_now_on_court')); - - // BTP - const btp_fieldset = uiu.el(form, 'fieldset'); - const btp_enabled_label = uiu.el(btp_fieldset, 'label'); - const ba_attrs = { - type: 'checkbox', - name: 'btp_enabled', - }; - if (curt.btp_enabled) { - ba_attrs.checked = 'checked'; + function calc_self_check_in_grid(match_count) { + if (match_count <= 1) { + return { cols: 1, rows: 1 }; + } + + let best = null; + for (let cols = 1; cols <= match_count; cols++) { + for (let rows = 1; rows <= cols; rows++) { + const area = cols * rows; + if (area < match_count) { + continue; + } + + const candidate = { + cols, + rows, + area, + diff: cols - rows, + }; + + if ( + !best || + candidate.cols < best.cols || + (candidate.cols === best.cols && candidate.area < best.area) || + (candidate.cols === best.cols && candidate.area === best.area && candidate.diff < best.diff) + ) { + best = candidate; + } + } + if (best && best.cols === cols) { + break; + } + } + + return { cols: best.cols, rows: best.rows }; } - uiu.el(btp_enabled_label, 'input', ba_attrs); - uiu.el(btp_enabled_label, 'span', {}, ci18n('tournament:edit:btp:enabled')); - const btp_autofetch_enabled_label = uiu.el(btp_fieldset, 'label'); - const bae_attrs = { - type: 'checkbox', - name: 'btp_autofetch_enabled', - }; - if (curt.btp_autofetch_enabled) { - bae_attrs.checked = 'checked'; + function render_self_check_in(container) { + uiu.empty(container); + + const matches = get_self_check_in_matches(); + container.setAttribute('data-match-count', String(matches.length)); + if (matches.length === 0) { + uiu.el(container, 'div', 'self_check_in_empty', ci18n('Self-Check-In: empty')); + return; + } + + const list = uiu.el(container, 'div', 'self_check_in_list'); + const grid = calc_self_check_in_grid(matches.length); + const display_cols = grid.rows; + const display_rows = grid.cols; + const density = Math.max(display_cols, display_rows); + let scale = Math.max(0.42, Math.min(1, 1.75 / density)); + if (display_cols === 2) { + scale *= 1.5; + } else if (display_cols === 1) { + scale *= 2; + } + scale = Math.min(scale, 2); + const chip_name_scale = Math.max(0.9, Math.min(1.75, 3 / display_rows)); + const chip_box_scale = Math.max(0.72, Math.min(1.6, 2.4 / display_rows)); + list.style.gridTemplateColumns = 'repeat(' + display_cols + ', minmax(0, 1fr))'; + list.style.gridTemplateRows = 'repeat(' + display_rows + ', minmax(0, 1fr))'; + list.style.setProperty('--self-check-in-scale', String(scale)); + list.style.setProperty('--self-check-in-chip-name-scale', String(chip_name_scale)); + list.style.setProperty('--self-check-in-chip-box-scale', String(chip_box_scale)); + matches.forEach((match) => render_self_check_in_match_card(list, match, false)); + schedule_fit_self_check_in_cards(list); } - uiu.el(btp_autofetch_enabled_label, 'input', bae_attrs); - uiu.el(btp_autofetch_enabled_label, 'span', {}, ci18n('tournament:edit:btp:autofetch_enabled')); - const btp_readonly_label = uiu.el(btp_fieldset, 'label'); - const bro_attrs = { - type: 'checkbox', - name: 'btp_readonly', - }; - if (curt.btp_readonly) { - bro_attrs.checked = 'checked'; - } - uiu.el(btp_readonly_label, 'input', bro_attrs); - uiu.el(btp_readonly_label, 'span', {}, ci18n('tournament:edit:btp:readonly')); - - const btp_ip_label = uiu.el(btp_fieldset, 'label'); - uiu.el(btp_ip_label, 'span', {}, ci18n('tournament:edit:btp:ip')); - uiu.el(btp_ip_label, 'input', { - type: 'text', - name: 'btp_ip', - value: (curt.btp_ip || ''), - }); + function show_self_check_in_called_match(match) { + const container = document.querySelector('.self_check_in_container'); + if (!container || !match || !match.setup) { + return; + } - const btp_password_label = uiu.el(btp_fieldset, 'label'); - uiu.el(btp_password_label, 'span', {}, ci18n('tournament:edit:btp:password')); - uiu.el(btp_password_label, 'input', { - type: 'text', - name: 'btp_password', - value: (curt.btp_password || ''), - }); + const existing = container.querySelector('.self_check_in_called_overlay'); + if (existing) { + existing.remove(); + } - // BTP timezone - const btp_timezone_label = uiu.el(btp_fieldset, 'label'); - uiu.el(btp_timezone_label, 'span', {}, ci18n('tournament:edit:btp:timezone')); - const btp_timezone_select = uiu.el(btp_timezone_label, 'select', { - name: 'btp_timezone', - }); - uiu.el( - btp_timezone_select, 'option', {value: 'system'}, - ci18n('tournament:edit:btp:system timezone', {tz: curt.system_timezone})); - let marked = false; - for (const tz of timezones.ALL_TIMEZONES) { - const attrs = { - value: tz, + const overlay = uiu.el(container, 'div', 'self_check_in_called_overlay'); + const backdrop = uiu.el(overlay, 'div', 'self_check_in_called_overlay_backdrop'); + backdrop.addEventListener('click', () => overlay.remove()); + const card_host = uiu.el(overlay, 'div', 'self_check_in_called_overlay_host'); + render_self_check_in_match_card(card_host, match, false); + const card = card_host.querySelector('.self_check_in_match'); + if (card) { + card.classList.add('self_check_in_called_overlay_card'); + const status_el = card.querySelector('.self_check_in_match_status'); + if (status_el) { + status_el.textContent = ci18n('Self-Check-In: called'); + } + schedule_fit_self_check_in_cards(card); } - if ((tz === curt.btp_timezone) && !marked) { - marked = true; - attrs.selected = 'selected'; + if (self_check_in_called_overlay_timeout) { + clearTimeout(self_check_in_called_overlay_timeout); } + const overlay_duration_ms = Math.max(1000, Number(curt.self_check_in_called_overlay_duration_ms || 12000)); + self_check_in_called_overlay_timeout = setTimeout(() => { + if (overlay.isConnected) { + overlay.remove(); + } + self_check_in_called_overlay_timeout = null; + }, overlay_duration_ms); + } - uiu.el(btp_timezone_select, 'option', attrs, tz); + function ui_upcoming() { + current_view = 'upcoming'; + const main = ui_match_screens('t/:key/upcoming'); + render_upcoming(main); } - // Ticker - const ticker_fieldset = uiu.el(form, 'fieldset'); - const ticker_enabled_label = uiu.el(ticker_fieldset, 'label'); - const te_attrs = { - type: 'checkbox', - name: 'ticker_enabled', - }; - if (curt.ticker_enabled) { - te_attrs.checked = 'checked'; - } - uiu.el(ticker_enabled_label, 'input', te_attrs); - uiu.el(ticker_enabled_label, 'span', {}, ci18n('tournament:edit:ticker_enabled')); - - const ticker_url_label = uiu.el(ticker_fieldset, 'label'); - uiu.el(ticker_url_label, 'span', {}, ci18n('tournament:edit:ticker_url')); - uiu.el(ticker_url_label, 'input', { - type: 'text', - name: 'ticker_url', - value: (curt.ticker_url || ''), - }); + function ui_current_matches() { + current_view = 'current_matches'; + const main = ui_match_screens('t/:key/current_matches'); + render_current_matches(main); + } - const ticker_password_label = uiu.el(ticker_fieldset, 'label'); - uiu.el(ticker_password_label, 'span', {}, ci18n('tournament:edit:ticker_password')); - uiu.el(ticker_password_label, 'input', { - type: 'text', - name: 'ticker_password', - value: (curt.ticker_password || ''), - }); + function ui_next_matches() { + current_view = 'next_matches'; + const main = ui_match_screens('t/:key/next_matches'); + render_next_matches(main); + } - uiu.el(form, 'button', { - role: 'submit', - }, ci18n('Change')); - form_utils.onsubmit(form, function(data) { - const props = { - name: data.name, - language: data.language, - is_team: (!!data.is_team), - is_nation_competition: (!!data.is_nation_competition), - only_now_on_court: (!!data.only_now_on_court), - btp_enabled: (!!data.btp_enabled), - btp_autofetch_enabled: (!!data.btp_autofetch_enabled), - btp_readonly: (!!data.btp_readonly), - btp_ip: data.btp_ip, - btp_password: data.btp_password, - btp_timezone: data.btp_timezone, - dm_style: data.dm_style, - ticker_enabled: (!! data.ticker_enabled), - ticker_url: data.ticker_url, - ticker_password: data.ticker_password, - }; - send({ - type: 'tournament_edit_props', - key: curt.key, - props: props, - }, function(err) { - if (err) { - return cerror.net(err); - } - ui_show(); + function ui_self_check_in() { + current_view = 'self_check_in'; + const main = ui_match_screens('t/:key/self_check_in', { + enable_fullscreen_toggle: false, + main_class: 'main_self_check_in', }); - }); + const container = uiu.el(main, 'div', 'self_check_in_container'); + render_self_check_in(container); + } - const logo_preview_container = uiu.el(main, 'div', { - style: ( - 'float:right;position:relative;text-align:center;' + - 'height: 216px; width: 384px; font-size: 35px;' + - 'background:' + (curt.logo_background_color || '#000000') + ';' + - 'color:' + (curt.logo_foreground_color || '#aaaaaa') + ';' - ), - }); - if (curt.logo_id) { - uiu.el(logo_preview_container, 'img', { - style: 'height: 151px;', - src: '/h/' + encodeURIComponent(curt.key) + '/logo/' + curt.logo_id, - }); - uiu.el(logo_preview_container, 'div', {}, 'Court 42'); + function ui_match_screens(route, options) { + options = options || {}; + crouting.set(route, { key: curt.key }); + toprow.hide(); + const main = uiu.qs('.main'); + uiu.empty(main); + main.classList.remove('main_upcoming', 'main_self_check_in'); + main.classList.add(options.main_class || 'main_upcoming'); + main.onclick = null; + if (options.enable_fullscreen_toggle !== false) { + main.onclick = () => fullscreen.toggle(); + } + return main; } - uiu.el(main, 'h2', {}, ci18n('tournament:edit:logo')); - const logo_form = uiu.el(main, 'form'); - const logo_button = uiu.el(logo_form, 'input', { - type: 'file', - accept: 'image/*', - }); - logo_button.addEventListener('change', _upload_logo); - const logo_colors_container = uiu.el(logo_form, 'div', {style: 'display: block'}); - const bg_col_label = uiu.el(logo_colors_container, 'label', {}, ci18n('tournament:edit:logo:background')); - const logo_background_color_input = uiu.el(bg_col_label, 'input', { - type: 'color', - name: 'logo_background_color', - value: curt.logo_background_color || '#000000', - }); - logo_background_color_input.addEventListener('change', (e) => { - send({ - type: 'tournament_edit_props', - key: curt.key, - props: { - logo_background_color: e.target.value, - }, - }, function(err) { - if (err) { - return cerror.net(err); - } - }); - }); - const fg_col_label = uiu.el(logo_colors_container, 'label', {}, ci18n('tournament:edit:logo:foreground')); - const fg_col_input = uiu.el(fg_col_label, 'input', { - type: 'color', - name: 'logo_foreground_color', - value: curt.logo_foreground_color || '#aaaaaa', - }); - fg_col_input.addEventListener('change', (e) => { - send({ - type: 'tournament_edit_props', - key: curt.key, - props: { - logo_foreground_color: e.target.value, - }, - }, function(err) { - if (err) { - return cerror.net(err); + function handle_view_announcement(kind, payload) { + if (current_view === 'self_check_in') { + if (kind === 'match_called_on_court') { + show_self_check_in_called_match(payload); } - }); - }); + return true; + } + return false; + } - uiu.el(main, 'h2', {}, ci18n('tournament:edit:courts')); + _route_single(/t\/([a-z0-9]+)\/upcoming/, ui_upcoming, change.default_handler(_update_all_ui_elements_upcoming, { + score: update_score, + court_current_match: update_upcoming_current_match, + match_edit: update_upcoming_match, + update_player_status: update_player_status, + })); - const courts_table = uiu.el(main, 'table'); - const courts_tbody = uiu.el(courts_table, 'tbody'); - for (const c of curt.courts) { - const tr = uiu.el(courts_tbody, 'tr'); - uiu.el(tr, 'th', {}, c.num); - uiu.el(tr, 'td', {}, c.name || ''); - const actions_td = uiu.el(tr, 'td', {}); - const del_btn = uiu.el(actions_td, 'button', { - 'data-court-id': c._id, - }, 'Delete'); - del_btn.addEventListener('click', function(e) { - const del_btn = e.target; - const court_id = del_btn.getAttribute('data-court-id'); - if (confirm('Do you really want to delete ' + court_id + '? (Will not do anything yet!)')) { - debug.log('TODO: would now delete court'); + _route_single(/t\/([a-z0-9]+)\/current_matches/, ui_current_matches, change.default_handler(_update_all_ui_elements_current_matches, { + score: update_score, + court_current_match: update_upcoming_current_match, + match_edit: update_upcoming_match, + update_player_status: update_player_status, + })); + _route_single(/t\/([a-z0-9]+)\/next_matches/, ui_next_matches, change.default_handler(_update_all_ui_elements_next_matches, { + score: update_score, + court_current_match: update_upcoming_current_match, + match_edit: update_upcoming_match, + update_player_status: update_player_status, + })); + + function _update_all_ui_elements_self_check_in() { + render_self_check_in(uiu.qs('.self_check_in_container')); + } + + function schedule_fit_self_check_in_cards(scope) { + self_check_in_fit_roots.add(scope || document); + if (self_check_in_fit_scheduled) { + return; + } + self_check_in_fit_scheduled = true; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const cards = new Set(); + self_check_in_fit_roots.forEach((root) => { + if (!root) { + return; + } + if (root.classList && root.classList.contains('self_check_in_match')) { + cards.add(root); + } + root.querySelectorAll?.('.self_check_in_match').forEach((card) => cards.add(card)); + }); + self_check_in_fit_roots.clear(); + self_check_in_fit_scheduled = false; + cards.forEach((card) => { + if (card && card.isConnected) { + fit_self_check_in_card(card); + } + }); + }); + }); + } + + function schedule_self_check_in_resize_recalc() { + if (current_view !== 'self_check_in') { + return; + } + if (self_check_in_resize_frame) { + cancelAnimationFrame(self_check_in_resize_frame); + } + self_check_in_resize_frame = requestAnimationFrame(() => { + self_check_in_resize_frame = null; + self_check_in_chip_fit_cache = Object.create(null); + self_check_in_layout_fit_cache = new Map(); + const container = document.querySelector('.self_check_in_container'); + if (container) { + render_self_check_in(container); } }); } - const nums = curt.courts.map(c => parseInt(c.num)); - const maxnum = Math.max(0, Math.max.apply(null, nums)); + window.addEventListener('resize', schedule_self_check_in_resize_recalc); - const courts_add_form = uiu.el(main, 'form'); - uiu.el(courts_add_form, 'input', { - type: 'number', - name: 'count', - min: 1, - max: 99, - value: 1, - }); - const courts_add_button = uiu.el(courts_add_form, 'button', { - role: 'button', - }, 'Add Courts'); - form_utils.onsubmit(courts_add_form, function(data) { - courts_add_button.setAttribute('disabled', 'disabled'); - const court_count = parseInt(data.count); - const nums = []; - for (let court_num = maxnum + 1;court_num <= maxnum + court_count;court_num++) { - nums.push(court_num); - } + _route_single(/t\/([a-z0-9]+)\/self_check_in/, ui_self_check_in, change.default_handler(_update_all_ui_elements_self_check_in, { + score: function(c) { + const before_structure_signature = self_check_in_match_structure_signature(utils.find(curt.matches, m => m._id === c.val.match_id)); + const before_status_signature = self_check_in_match_status_signature(utils.find(curt.matches, m => m._id === c.val.match_id)); + update_score(c); + rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, c.val.match_id); + }, + court_current_match: function(c) { + const before_structure_signature = self_check_in_match_structure_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + const before_status_signature = self_check_in_match_status_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + update_upcoming_current_match(c); + rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, c.val.match__id); + }, + match_edit: function(c) { + const before_structure_signature = self_check_in_match_structure_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + const before_status_signature = self_check_in_match_status_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + update_match(c); + rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, c.val.match__id); + }, + update_player_status: function(c) { + const before_structure_signature = self_check_in_match_structure_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + const before_status_signature = self_check_in_match_status_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + update_player_status(c); + rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, c.val.match__id); + }, + match_preparation_call: function(c) { + const before_structure_signature = self_check_in_match_structure_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + const before_status_signature = self_check_in_match_status_signature(utils.find(curt.matches, m => m._id === c.val.match__id)); + const changed_match = c.val.match; + const cur_match = utils.find(curt.matches, m => m._id === c.val.match__id); + if (cur_match && changed_match) { + cur_match.setup = changed_match.setup; + cur_match.btp_winner = changed_match.btp_winner; + cur_match.team1_won = changed_match.team1_won; + cur_match.network_score = changed_match.network_score; + } + rerender_self_check_in_if_needed(before_structure_signature, before_status_signature, c.val.match__id); + }, + })); + + function init() { send({ - type: 'courts_add', - tournament_key: curt.key, - nums, - }, function(err, response) { + type: 'tournament_list', + }, function (err, response) { if (err) { - courts_add_button.removeAttribute('disabled'); return cerror.net(err); } - Array.prototype.push.apply(curt.courts, response.added_courts); - ui_edit(); - }); - }); -} -_route_single(/t\/([a-z0-9]+)\/edit$/, ui_edit); - - -function render_upcoming(container) { - cmatch.prepare_render(curt); - const courts_container = uiu.el(container, 'div'); - cmatch.render_courts(courts_container, 'public'); - const upcoming_container = uiu.el(container, 'div'); - cmatch.render_upcoming_matches(upcoming_container); -} - -function ui_upcoming() { - crouting.set('t/:key/upcoming', {key: curt.key}); - toprow.hide(); + const tournaments = response.tournaments; + if (tournaments.length === 1) { + switch_tournament(tournaments[0].key, ui_show); + } else { + list_show(tournaments); + } + }); + } + crouting.register(/^$/, init, change.default_handler); - const main = uiu.qs('.main'); - uiu.empty(main); - main.classList.add('main_upcoming'); + function _cancel_ui_allscoresheets() { + const dlg = document.querySelector('.allscoresheets_dialog'); + if (!dlg) { + return; // Already cancelled + } + cbts_utils.esc_stack_pop(); + uiu.remove(dlg); + ui_show(); + } - uiu.hide_qs('.btp_status'); - uiu.hide_qs('.ticker_status'); - uiu.hide_qs('.status'); + function _pad(n, width, z) { + z = z || '0'; + n = n + ''; + return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; + } - render_upcoming(main); - main.addEventListener('click', () => { - fullscreen.toggle(); - }); -} -_route_single(/t\/([a-z0-9]+)\/upcoming/, ui_upcoming); + function _render_scoresheet(task, pos, cb) { + const { + container, + status, + progress, + matches, + pseudo_state, + tournament_name, + zip } = task; -function init() { - send({ - type: 'tournament_list', - }, function(err, response) { - if (err) { - return cerror.net(err); + if (pos >= matches.length) { + return cb(); } - const tournaments = response.tournaments; - if (tournaments.length === 1) { - switch_tournament(tournaments[0].key, ui_show); - } else { - list_show(tournaments); - } - }); -} -crouting.register(/^$/, init, change.default_handler); + progress.value = pos; + uiu.text(status, 'Rendere ' + (pos + 1) + ' / ' + (matches.length)); -function _cancel_ui_allscoresheets() { - const dlg = document.querySelector('.allscoresheets_dialog'); - if (!dlg) { - return; // Already cancelled - } - cbts_utils.esc_stack_pop(); - uiu.remove(dlg); - ui_show(); -} + const match = matches[pos]; + const setup = utils.deep_copy(match.setup); + setup.tournament_name = curt.name; + let s = null; + try { + s = calc.remote_state(pseudo_state, setup, match.presses); + } catch (err) { + console.error(`[bts] bulk scoresheet remote_state failed for #${setup.match_num || '?'} (${match._id})`, err); + cerror.silent(`Scoresheet for #${setup.match_num || '?'} skipped: ${err.message}`); + return render_next_scoresheet(task, pos + 1, cb); + } + s.ui = {}; -function _pad(n, width, z) { - z = z || '0'; - n = n + ''; - return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; -} + scoresheet.load_sheet(scoresheet.sheet_name(setup), function (xml) { + var svg = scoresheet.make_sheet_node(s, xml); + svg.setAttribute('class', 'scoresheet single_scoresheet'); + // Usually we'd call importNode here to import the document here, but IE/Edge then ignores the styles + container.appendChild(svg); + scoresheet.sheet_render(s, svg); + const title = ( + tournament_name + ' ' + _pad(setup.match_num, 3, ' ') + ' ' + + setup.event_name + ' ' + setup.match_name + ' ' + + pronunciation.teamtext_internal(s, 0) + ' v ' + + pronunciation.teamtext_internal(s, 1)); + const props = { + title, + subject: 'Schiedsrichterzettel', + creator: 'bts with bup (https://github.com/phihag/bts/)', + }; + const pdf = svg2pdf.make([svg], props, 'landscape'); -function _render_scoresheet(task, pos, cb) { - const { - container, - status, - progress, - matches, - pseudo_state, - tournament_name, - zip} = task; - - if (pos >= matches.length) { - return cb(); - } - - progress.value = pos; - uiu.text(status, 'Rendere ' + (pos + 1) + ' / ' + (matches.length)); - - const match = matches[pos]; - const setup = utils.deep_copy(match.setup); - setup.tournament_name = curt.name; - const s = calc.remote_state(pseudo_state, setup, match.presses); - s.ui = {}; - - scoresheet.load_sheet(scoresheet.sheet_name(setup), function(xml) { - var svg = scoresheet.make_sheet_node(s, xml); - svg.setAttribute('class', 'scoresheet single_scoresheet'); - // Usually we'd call importNode here to import the document here, but IE/Edge then ignores the styles - container.appendChild(svg); - scoresheet.sheet_render(s, svg); - - const title = ( - tournament_name + ' ' + _pad(setup.match_num, 3, ' ') + ' ' + - setup.event_name + ' ' + setup.match_name + ' ' + - pronunciation.teamtext_internal(s, 0) + ' v ' + - pronunciation.teamtext_internal(s, 1)); - const props = { - title, - subject: 'Schiedsrichterzettel', - creator: 'bts with bup (https://github.com/phihag/bts/)', - }; - const pdf = svg2pdf.make([svg], props, 'landscape'); + const ab = pdf.output('arraybuffer'); + zip.file(title.replace(/\s*\/\s*/g, ', ') + '.pdf', ab); - const ab = pdf.output('arraybuffer'); - zip.file(title.replace(/\s*\/\s*/g, ', ') + '.pdf', ab); + uiu.empty(container); + progress.value = pos + 1; + setTimeout(function () { + _render_scoresheet(task, pos + 1, cb); + }, 0); + }, '/bupdev/'); + } - uiu.empty(container); - progress.value = pos + 1; - setTimeout(function() { - _render_scoresheet(task, pos + 1, cb); - }, 0); - }, '/bupdev/'); -} + function get_admin_subpage() { + const path = window.location.pathname; + const parts = path.split('/').filter(Boolean); // Entfernt leere Einträge (z. B. durch führendes '/') + + // Erwartet: ['admin', 't', 'TurnierName', 'subpage?'] + if (parts.length < 3 || parts[0] !== 'admin' || parts[1] !== 't') { + return null; // Nicht im erwarteten Admin-Pfad + } + + const subpage = parts[3]; // Kann undefined sein + + switch (subpage) { + case undefined: + return 'tournament-control'; + default: + return subpage; + } + } -function ui_allscoresheets() { - crouting.set('t/' + curt.key + '/allscoresheets', {}, _cancel_ui_allscoresheets); + function ui_allscoresheets() { + crouting.set('t/' + curt.key + '/allscoresheets', {}, _cancel_ui_allscoresheets); - cbts_utils.esc_stack_push(_cancel_ui_allscoresheets); + cbts_utils.esc_stack_push(_cancel_ui_allscoresheets); - const body = uiu.qs('body'); - const dialog_bg = uiu.el(body, 'div', 'dialog_bg allscoresheets_dialog'); - const dialog = uiu.el(dialog_bg, 'div', 'dialog'); + const body = uiu.qs('body'); + const dialog_bg = uiu.el(body, 'div', 'dialog_bg allscoresheets_dialog'); + const dialog = uiu.el(dialog_bg, 'div', 'dialog'); - uiu.el(dialog, 'h3', {}, 'Generiere Schiedsrichterzettel'); + uiu.el(dialog, 'h3', {}, 'Generiere Schiedsrichterzettel'); - const status = uiu.el(dialog, 'div', {}, 'Lade Daten ...'); + const status = uiu.el(dialog, 'div', {}, 'Lade Daten ...'); - const progress = uiu.el(dialog, 'progress', { - style: 'min-width: 60vw;', - }); - send({ - type: 'fetch_allscoresheets_data', - tournament_key: curt.key, - }, function (err, response) { - if (err) { - return cerror.net(err); - } + const progress = uiu.el(dialog, 'progress', { + style: 'min-width: 60vw;', + }); + send({ + type: 'fetch_allscoresheets_data', + tournament_key: curt.key, + }, function (err, response) { + if (err) { + return cerror.net(err); + } - const matches = response.matches; - progress.max = matches.length; - uiu.text(status, 'Starte Rendering (' + matches.length + ' Spiele)'); + const matches = response.matches; + progress.max = matches.length; + uiu.text(status, 'Starte Rendering (' + matches.length + ' Spiele)'); - const zip = new JSZip(); - const container = uiu.el(dialog, 'div', { - 'class': 'allscoresheets_svg_container', - }); - printing.set_orientation('landscape'); + const zip = new JSZip(); + const container = uiu.el(dialog, 'div', { + 'class': 'allscoresheets_svg_container', + }); + printing.set_orientation('landscape'); - const lang = 'en'; - const pseudo_state = { - settings: { - shuttle_counter: true, - }, - lang, - }; - i18n.update_state(pseudo_state, lang); - i18n.register_lang(i18n_de); - i18n.register_lang(i18n_en); + const lang = 'en'; + const pseudo_state = { + settings: { + shuttle_counter: true, + }, + lang, + }; + i18n.update_state(pseudo_state, lang); + i18n.register_lang(i18n_de); + i18n.register_lang(i18n_en); - const task = { - container, - status, - progress, - matches, - pseudo_state, - tournament_name: curt.name, - zip, - }; + const task = { + container, + status, + progress, + matches, + pseudo_state, + tournament_name: curt.name, + zip, + }; - _render_scoresheet(task, 0, function() { - uiu.text(status, 'Generiere Zip.'); - const zip_fn = curt.name + ' Schiedsrichterzettel.zip'; - zip.generateAsync({type: 'blob'}).then(function(blob) { - uiu.text(status, 'Starte Download.'); + _render_scoresheet(task, 0, function () { + uiu.text(status, 'Generiere Zip.'); + const zip_fn = curt.name + ' Schiedsrichterzettel.zip'; + zip.generateAsync({ type: 'blob' }).then(function (blob) { + uiu.text(status, 'Starte Download.'); - save_file(blob, zip_fn); - uiu.text(status, 'Fertig.'); - }).catch(function(error) { - uiu.text(status, 'Fehler: ' + error.stack); + save_file(blob, zip_fn); + uiu.text(status, 'Fertig.'); + }).catch(function (error) { + uiu.text(status, 'Fehler: ' + error.stack); + }); }); }); - }); - const cancel_btn = uiu.el(dialog, 'div', 'vlink', 'Zurück'); - cancel_btn.addEventListener('click', _cancel_ui_allscoresheets); -} -crouting.register(/t\/([a-z0-9]+)\/allscoresheets$/, function(m) { - ctournament.switch_tournament(m[1], function() { - ui_allscoresheets(); - }); -}, change.default_handler(ui_allscoresheets)); + const cancel_btn = uiu.el(dialog, 'div', 'vlink', 'Zurück'); + cancel_btn.addEventListener('click', _cancel_ui_allscoresheets); + } + crouting.register(/t\/([a-z0-9]+)\/allscoresheets$/, function (m) { + ctournament.switch_tournament(m[1], function () { + ui_allscoresheets(); + }); + }, change.default_handler(ui_allscoresheets)); -return { - init, - // For other modules - switch_tournament, - ui_show, - ui_list, -}; + return { + init, + // For other modules + switch_tournament, + ui_show, + ui_list, + add_match, + update_match, + update_officials, + update_upcoming_match, + update_location_preparation_need_labels, + update_logo, + update_display, + update_location, + update_location_logo, + update_court, + update_emergency_btn, + update_scoring_formats, + update_stages_scoring_formats, + apply_pending_official_role_override, + set_pending_official_role_override, + btp_status_changed, + ticker_status_changed, + bts_status_changed, + remove_normalization, + add_normalization, + remove_advertisement, + add_advertisement, + update_general_displaysettings, + update_metadata_settings, + update_edit_dependencies, + update_btp_settings_ui, + update_show_tabletoperators, + update_show_automation_controls, + close_scoring_format_dialog_if_open, + refresh_current_view, + handle_view_announcement, + delete_display, + }; })(); @@ -837,6 +7106,7 @@ if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { var cmatch = require('./cmatch'); var crouting = require('./crouting'); var cumpires = require('./cumpires'); + var ctabletoperator = require('./ctabletoperator'); var debug = require('./debug'); var form_utils = require('./form_utils'); var i18n = require('../bup/js/i18n'); diff --git a/static/js/cumpires.js b/static/js/cumpires.js index f6f0097..2c0468a 100644 --- a/static/js/cumpires.js +++ b/static/js/cumpires.js @@ -2,113 +2,93 @@ var cumpires = (function() { -function calc_umpire_status(t) { - if (!t.umpires) return []; - const umpires_by_name = new Map(); - for (const u of t.umpires) { - umpires_by_name.set(u.name, u); - if (u.paused_since_ts) { - u.status = 'paused'; - } else { - u.status = 'ready'; - } - } - - for (const m of t.matches) { - if (!m.setup.umpire_name) continue; - const u = umpires_by_name.get(m.setup.umpire_name); - if (!u) continue; - - if (m.end_ts) { - if (u.last_on_court_ts) { - u.last_on_court_ts = Math.max(m.end_ts, u.last_on_court_ts); - } else { - u.last_on_court_ts = m.end_ts; + function _ui_render_table(container, umpires) { + const table = uiu.el(container, 'table'); + const tbody = uiu.el(table, 'tbody'); + for (const u of umpires) { + + const tr = uiu.el(tbody, 'tr'); + const flag_td = uiu.el(tr, 'td'); + if (u.country) { + cflags.render_flag_el(flag_td, u.country); + } + uiu.el(tr, 'td', { + class: 'umpires_firstname', + title: ci18n('umpires:btp_id', { btp_id: u.btp_id }), + }, u.firstname); + uiu.el(tr, 'td', { + class: 'umpires_name', + title: ci18n('umpires:btp_id', {btp_id: u.btp_id}), + }, u.surname); + if (u.status === 'oncourt') { + const td = uiu.el(tr, 'td', 'umpires_since', ''); + let parts = u.court_id.split("_"); + let court_number = parts[parts.length - 1]; + uiu.el(td, 'div', 'court', court_number) + } else if (u.status === 'standby') { + uiu.el(tr, 'td', 'umpires_since', 'In Vorbereitung'); + } else { + var timer_state = _extract_umpire_timer_state(u); + var timer = cmatch.create_timer(timer_state, uiu.el(tr, 'td', 'umpires_since', ''), "#ffffff", "#ffffff"); } - } - if (typeof m.team1_won !== 'boolean') { - u.status = 'oncourt'; } } - const umpires = Array.from(umpires_by_name.values()); - umpires.sort((u0, u1) => { - if (!u0.last_on_court_ts && u1.last_on_court_ts) return -1; - if (u0.last_on_court_ts && !u1.last_on_court_ts) return 1; - let cmp = utils.cmp_key('last_on_court_ts')(u0, u1); - if (cmp !== 0) return cmp; - return utils.cmp_key('name')(u0, u1); - }); - return umpires; -} - -function _ui_render_table(container, umpires, status) { - const table = uiu.el(container, 'table'); - const tbody = uiu.el(table, 'tbody'); - for (const u of umpires) { - if (u.status !== status) continue; - const tr = uiu.el(tbody, 'tr'); - if (curt.is_nation_competition) { - const flag_td = uiu.el(tr, 'td'); - cflags.render_flag_el(flag_td, u.nationality); - } - uiu.el(tr, 'td', { - title: ci18n('umpires:btp_id', {btp_id: u.btp_id}), - }, u.name); - if (status === 'paused') { - uiu.el(tr, 'td', 'umpires_since', - (u.paused_since_ts ? ci18n('umpires:paused_since', {time: utils.time_str(u.paused_since_ts)}) : '')); + function ui_status(container) { + uiu.empty(container); + var umpires = curt.umpires; + if (umpires.length > 0) { + container.style.display = "block"; + umpires = umpires.sort((a, b) => { + + + if (a.status === b.status) { + + if (!b.last_time_on_court_ts) { + return 1; + } else if (!a.last_time_on_court_ts) { + return -1; + } else { + return a.last_time_on_court_ts - b.last_time_on_court_ts; + } + } else { + if (a.status === "oncourt") { + return 1; + } + if (a.status === "ready") { + return -1; + } + return 0; + } + }); + uiu.el(container, 'h3', {}, ci18n('Umpire:')); + const tableoperator_content = uiu.el(container, 'div', 'umpire_container_content'); + _ui_render_table(tableoperator_content, umpires, 'ready'); + } else { + container.style.display = "none"; } - uiu.el(tr, 'td', 'umpires_since', - (u.last_on_court_ts ? ci18n('umpires:last_on_court', {time: utils.time_str(u.last_on_court_ts)}) : '')); - } -} - -function _ui_status_update() { - const container = uiu.qs('.umpires_status'); - uiu.empty(container); - - cmatch.render_courts(container, 'umpires'); - - const umpires = calc_umpire_status(curt); - - uiu.el(container, 'h3', {}, ci18n('umpires:status:ready')); - _ui_render_table(container, umpires, 'ready'); - uiu.el(container, 'h3', {}, ci18n('umpires:status:paused')); - _ui_render_table(container, umpires, 'paused'); -} - -function ui_status() { - crouting.set('t/:key/umpires', {key: curt.key}); - toprow.set([{ - label: ci18n('Tournaments'), - func: ctournament.ui_list, - }, { - label: curt.name || curt.key, - func: ctournament.ui_show, - 'class': 'ct_name', - }, { - label: ci18n('umpires:status:heading'), - }]); - - const main = uiu.qs('.main'); - uiu.empty(main); + } - uiu.el(main, 'div', 'umpires_status'); - _ui_status_update(); -} -crouting.register(/t\/([a-z0-9]+)\/umpires$/, function(m) { - ctournament.switch_tournament(m[1], function() { - ui_status(); - }); -}, change.default_handler(ui_status)); + function _extract_umpire_timer_state(umpire) { + let s = {}; + s.settings = {}; + s.settings.negative_timers = false; + s.lang = "de"; + s.timer = {}; + s.timer.duration = curt.btp_settings.pause_duration_ms; + s.timer.start = (umpire.last_time_on_court_ts ? umpire.last_time_on_court_ts : false); + s.timer.upwards = false; + s.timer.exigent = false; + s.bgColor = "#FF0000"; + return s; + } -return { - ui_status, -}; + return { + ui_status, + }; })(); diff --git a/static/js/match_scoring.js b/static/js/match_scoring.js new file mode 100644 index 0000000..198e939 --- /dev/null +++ b/static/js/match_scoring.js @@ -0,0 +1,92 @@ +'use strict'; + +var match_scoring = (function() { + function fallback_scoring_format() { + return { + numSets: 3, + set_points: { + end_points: 21, + max_points: 30, + }, + last_set_points: { + end_points: 21, + max_points: 30, + }, + }; + } + + function normalize_set_points(setPoints, fallbackSetPoints) { + const normalized = setPoints || {}; + const fallback = fallbackSetPoints || {}; + + return { + end_points: Number.isFinite(normalized.end_points) ? normalized.end_points : fallback.end_points, + max_points: Number.isFinite(normalized.max_points) ? normalized.max_points : fallback.max_points, + }; + } + + function is_set_over(scoreA, scoreB, setPoints) { + const maxScore = setPoints?.max_points; + const winningScore = setPoints?.end_points; + + if (Number.isFinite(maxScore) && (scoreA === maxScore || scoreB === maxScore)) return true; + + if (Number.isFinite(winningScore) && + (scoreA >= winningScore || scoreB >= winningScore) && + Math.abs(scoreA - scoreB) >= 2) { + return true; + } + + return false; + } + + function is_match_over(sets, scoringFormat) { + if (!sets) { + return false; + } + + const format = scoringFormat || fallback_scoring_format(); + const totalSets = Number.isFinite(format.numSets) && format.numSets > 0 ? format.numSets : 3; + const requiredWins = Math.floor(totalSets / 2) + 1; + const fallbackSetPoints = fallback_scoring_format().set_points; + + let winsA = 0; + let winsB = 0; + + for (let idx = 0; idx < sets.length; idx++) { + const [scoreA, scoreB] = sets[idx]; + const isLastPossibleSet = idx === totalSets - 1; + const setPoints = normalize_set_points( + isLastPossibleSet ? format.last_set_points : format.set_points, + fallbackSetPoints + ); + + if (is_set_over(scoreA, scoreB, setPoints)) { + if (scoreA > scoreB) { + winsA++; + } else { + winsB++; + } + + if (winsA >= requiredWins || winsB >= requiredWins) { + return true; + } + } else { + return false; + } + } + + return winsA >= requiredWins || winsB >= requiredWins; + } + + return { + is_match_over, + is_set_over, + }; +})(); + +/*@DEV*/ +if ((typeof module !== 'undefined') && (typeof require !== 'undefined')) { + module.exports = match_scoring; +} +/*/@DEV*/ diff --git a/static/js/toprow.js b/static/js/toprow.js index e1c9cbf..9097ed7 100644 --- a/static/js/toprow.js +++ b/static/js/toprow.js @@ -10,6 +10,35 @@ function update_container(container, elems, with_sep) { uiu.el(container, 'span', 'toprow_sep', '>'); } + if (el.class === 'toprow_menu_separator') { + uiu.el(container, 'div', 'toprow_menu_separator'); + return; + } + + if (el.items && Array.isArray(el.items)) { + const menu = uiu.el(container, 'div', 'toprow_menu'); + uiu.el(menu, 'span', 'toprow_link vlink toprow_menu_label', el.label); + const dropdown = uiu.el(menu, 'div', 'toprow_menu_dropdown'); + el.items.forEach(function(item) { + if (item.class === 'toprow_menu_separator') { + uiu.el(dropdown, 'div', 'toprow_menu_separator'); + return; + } + const item_attrs = { + 'class': 'toprow_menu_item' + ((item.func || item.href) ? ' vlink' : '') + (item.class ? (' ' + item.class) : ''), + 'data-label': item.label, + }; + if (item.href) { + item_attrs.href = item.href; + } + const item_el = uiu.el(dropdown, (item.href ? 'a' : 'span'), item_attrs, item.label); + if (item.func) { + item_el.addEventListener('click', item.func); + } + }); + return; + } + const css_class = 'toprow_link' + ((el.func || el.href) ? ' vlink' : '') + (el.class ? (' ' + el.class) : ''); const attrs = { 'class': css_class, diff --git a/static/ticker/courts.mustache b/static/ticker/courts.mustache index c6ceb9f..f03fd07 100644 --- a/static/ticker/courts.mustache +++ b/static/ticker/courts.mustache @@ -1,25 +1,31 @@ -{{#courts_with_matches}} - - -{{^match}} - -{{/match}} - -{{#match}} - -{{#team0scores}} - -{{/team0scores}} - -{{/match}} - - - -{{#match}} - -{{#team1scores}} - -{{/team1scores}} -{{/match}} -
{{num}}{{p0str}}{{.}}
{{n}}
{{p1str}}{{.}}
+{{#courts_with_matches}} + + + + + + + + {{^match}} + + {{/match}} + {{#match}} + + {{#team0scores}} + + {{/team0scores}} + {{/match}} + + + {{#match}} + + {{#team1scores}} + + {{/team1scores}} + {{/match}} + + +
+ # {{#match}}{{n}}{{/match}} +
{{num}}{{p0str}}{{.}}
{{p1str}}{{.}}
{{/courts_with_matches}} \ No newline at end of file diff --git a/static/ticker/root.html b/static/ticker/root.html index c45805a..499ccbc 100644 --- a/static/ticker/root.html +++ b/static/ticker/root.html @@ -1,29 +1,26 @@ - - - - -{{tournament_name_html}} - - - -
- -
-

{{tournament_name_html}}

Last update: {{last_update_str}}
-
- -
{{prefix_html}}
- -
{{courts_html}}
- -
{{note_html}}
-
-
- - - - - + + + + + {{tournament_name_html}} + + +
+
+ +

{{tournament_name_html}}

+
+
{{prefix_html}}
+
{{courts_html}}
+
+ {{note_html}} +
Stand: {{last_update_str}}
+
+
+
+ + + \ No newline at end of file diff --git a/static/ticker/ticker.css b/static/ticker/ticker.css index 79c38cc..ea5dc19 100644 --- a/static/ticker/ticker.css +++ b/static/ticker/ticker.css @@ -1,7 +1,6 @@ html, body { margin: 0; padding: 0; - font-size: 20px; font-family: sans-serif; } .main { @@ -10,24 +9,30 @@ html, body { .topline { position: relative; - margin: 0.3em 0 0.5em 0; + align-items: center; + display: flex; + margin: 0.3em 0 0 0; } +.tournament_logo { + height: 32px; +} h1 { - margin: 0; + margin: 0 0 0 10px; padding: 0; font-size: inherit; font-weight: bold; - font-size: 150%; + font-size: 120%; } .last_update { - position: absolute; - right: 0; - top: 0; + float: right; + font-size: 10px; + font-weight: bold; } .note { margin-top: 1em; + font-size: 12px; } table.court { @@ -56,6 +61,8 @@ table.court + table.court { text-align: right; padding: 0 0.5em 0 0.1em; width: 1em; + font-size: 20px; + font-weight: bold; } .court_players { padding-left: 0.6em; @@ -63,14 +70,15 @@ table.court + table.court { } .court_score { width: 1.3em; - font-size: 40px; + font-size: 18px; border-left: 0.1vmin solid #ddd; text-align: center; } .court_event, .court_event_div { width: 2em; max-width: 2em; - font-size: 20px; + font-size: 12px; + font-weight: bold; } .court_event>div { width: 100%; diff --git a/test/test_admin.js b/test/test_admin.js new file mode 100644 index 0000000..848fca5 --- /dev/null +++ b/test/test_admin.js @@ -0,0 +1,75 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const admin = require('../bts/admin.js'); + +_describe('admin', () => { + _it('marks official changes in match edit as pending sync and suppresses removed BTP officials', () => { + const old_setup = { + umpire: { _id: 'u1', btp_id: 6, name: 'Stefan Schiedsrichter' }, + service_judge: { _id: 'u2', btp_id: 7, name: 'Michael G-Punkt' } + }; + const new_setup = { + service_judge: { _id: 'u2', btp_id: 7, name: 'Michael G-Punkt' } + }; + + const result = admin._build_match_edit_official_sync_meta(old_setup, new_setup); + + assert.strictEqual(result.has_official_change, true); + assert.strictEqual(new_setup.suppressed_umpire_btp_id, 6); + assert.strictEqual(new_setup.suppressed_service_judge_btp_id, undefined); + }); + + _it('clears stale suppress flag when a new official is set in the same role', () => { + const old_setup = { + umpire: { _id: 'u1', btp_id: 6, name: 'Stefan Schiedsrichter' } + }; + const new_setup = { + umpire: { _id: 'u3', btp_id: 9, name: 'Ralf Referee' }, + suppressed_umpire_btp_id: 6 + }; + + const result = admin._build_match_edit_official_sync_meta(old_setup, new_setup); + + assert.strictEqual(result.has_official_change, true); + assert.strictEqual(new_setup.suppressed_umpire_btp_id, undefined); + }); + + _it('releases dependent service judge when umpire is removed from a setup', () => { + const setup = { + service_judge: { _id: 'u2', btp_id: 7, name: 'Michael G-Punkt' } + }; + + const releases = admin._collect_dependent_official_releases(setup); + + assert.deepStrictEqual(releases, [{ + official_id: 'u2', + wait_field: 'service_judge_wait', + target_position: 'front' + }]); + assert.strictEqual(setup.service_judge, undefined); + assert.strictEqual(setup.suppressed_service_judge_btp_id, 7); + }); + + _it('removing an umpire from a setup also releases a dependent service judge', () => { + const setup = { + umpire: { _id: 'u1', btp_id: 6, name: 'Stefan Schiedsrichter' }, + service_judge: { _id: 'u2', btp_id: 7, name: 'Michael G-Punkt' } + }; + + const releases = admin._remove_official_from_setup(setup, 'umpire'); + + assert.strictEqual(setup.umpire, undefined); + assert.strictEqual(setup.service_judge, undefined); + assert.strictEqual(setup.suppressed_umpire_btp_id, 6); + assert.strictEqual(setup.suppressed_service_judge_btp_id, 7); + assert.deepStrictEqual(releases, [{ + official_id: 'u2', + wait_field: 'service_judge_wait', + target_position: 'front' + }]); + }); +}); diff --git a/test/test_btp_proto.js b/test/test_btp_proto.js index 9ca50a9..6384f6f 100644 --- a/test/test_btp_proto.js +++ b/test/test_btp_proto.js @@ -1,29 +1,45 @@ 'use strict'; -const assert = require('assert').strict; -const fs = require('fs'); -const path = require('path'); +const assert = require('assert'); +const { _describe, _it } = require('./tutils.js'); +const btp_proto = require('../bts/btp_proto.js'); -const tutils = require('./tutils.js'); -const _describe = tutils._describe; -const _it = tutils._it; +function extract_first_match_status(req) { + return req.Update.Tournament.Matches[0].Match.Status; +} -const {_req2xml: req2xml} = require('../bts/btp_proto'); +_describe('btp_proto update_request', () => { + _it('writes match check-in bits in check-in per match mode', () => { + const req = btp_proto.update_request({ + btp_match_ids: [{ id: 1, draw: 2, planning: 3 }], + setup: { + highlight: 0, + teams: [ + { players: [{ checked_in: true }, { checked_in: false }] }, + { players: [{ checked_in: true }, { checked_in: true }] }, + ] + } + }, 'unicode', null, null, null, null, { + write_match_check_in_status: true, + }); + assert.strictEqual(extract_first_match_status(req), 0b1101); + }); + + _it('does not write match check-in bits in check-in per player mode', () => { + const req = btp_proto.update_request({ + btp_match_ids: [{ id: 1, draw: 2, planning: 3 }], + setup: { + highlight: 0, + teams: [ + { players: [{ checked_in: true }, { checked_in: true }] }, + { players: [{ checked_in: true }, { checked_in: true }] }, + ] + } + }, 'unicode', null, null, null, null, { + write_match_check_in_status: false, + }); -_describe('btp_proto', function() { - _it('Timezone encoding', async function() { - assert.deepStrictEqual( - req2xml({test_date: new Date(1652529397790)}, 'Europe/Berlin'), - ('' + - '' + - '' + - '')); - assert.deepStrictEqual( - req2xml({test_date: new Date(1652529397790)}, 'America/New_York'), - ('' + - '' + - '' + - '')); + assert.strictEqual(extract_first_match_status(req), 0); }); }); diff --git a/test/test_btp_sync.js b/test/test_btp_sync.js new file mode 100644 index 0000000..21fdf37 --- /dev/null +++ b/test/test_btp_sync.js @@ -0,0 +1,738 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const btp_sync = require('../bts/btp_sync'); + +_describe('btp_sync', () => { + _it('normalizes standard scoring formats from BTP fields', () => { + const normalized = btp_sync._normalize_scoring_format({ + ID: ['10'], + Name: ['Best of 3 to 21'], + NumSets: ['3'], + SetType: ['0'], + LastSetType: ['0'], + Score: ['21'], + IsDefault: [true], + }); + + assert.deepStrictEqual(normalized, { + id: 10, + name: 'Best of 3 to 21', + numSets: 3, + score: 21, + isDefault: true, + setType: 0, + lastSetType: 0, + set_points: { + end_points: 21, + max_points: 30, + end_points_editable: false, + max_points_editable: false, + interval_at: 11, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }, + last_set_points: { + end_points: 21, + max_points: 30, + end_points_editable: false, + max_points_editable: false, + interval_at: 11, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }, + }); + }); + + _it('normalizes editable scoring formats using the score fallback', () => { + const normalized = btp_sync._normalize_scoring_format({ + ID: ['11'], + Name: ['Custom 1x17'], + NumSets: ['1'], + SetType: ['999'], + LastSetType: ['999'], + Score: ['17'], + IsDefault: [false], + }); + + assert.deepStrictEqual(normalized.set_points, { + end_points: 17, + max_points: 17, + end_points_editable: false, + max_points_editable: true, + defaults_from_score: true, + interval_at: 9, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + assert.deepStrictEqual(normalized.last_set_points, { + end_points: 17, + max_points: 17, + end_points_editable: false, + max_points_editable: true, + defaults_from_score: true, + interval_at: 9, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + }); + + _it('normalizes fully editable set rules for set type 1000', () => { + const normalized = btp_sync._normalize_scoring_format({ + ID: ['13'], + Name: ['Custom free'], + NumSets: ['3'], + SetType: ['1000'], + LastSetType: ['1000'], + Score: ['0'], + IsDefault: [false], + }); + + assert.deepStrictEqual(normalized.set_points, { + end_points: 1, + max_points: 1, + end_points_editable: true, + max_points_editable: true, + interval_at: 1, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + assert.deepStrictEqual(normalized.last_set_points, { + end_points: 1, + max_points: 1, + end_points_editable: true, + max_points_editable: true, + interval_at: 1, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + }); + + _it('defaults interval point to rounded-up half of end points', () => { + const normalized = btp_sync._normalize_scoring_format({ + ID: ['14'], + Name: ['Custom 1x15'], + NumSets: ['1'], + SetType: ['999'], + LastSetType: ['999'], + Score: ['15'], + IsDefault: [false], + }); + + assert.strictEqual(normalized.set_points.interval_at, 8); + assert.strictEqual(normalized.last_set_points.interval_at, 8); + }); + + _it('resolves direct visible predecessor links for placement matches without importing extra matches', () => { + const planning_nodes = new Map([ + ['37_4009', { + DrawID: ['37'], + PlanningID: ['4009'], + IsMatch: [true], + MatchNr: ['18'], + WinnerTo: ['3005'], + LoserTo: ['3009'], + PlannedTime: [{ year: 2026, month: 4, day: 18, hour: 11, minute: 30 }], + }], + ]); + + const label = btp_sync._resolve_btp_dependency_link('37', '4009', '3005', [], planning_nodes); + + assert.strictEqual(label, 'Gewinner #18 - 2026-04-18 11:30'); + }); + + _it('resolves hidden loser slots via the visible consolidation match number', () => { + const planning_nodes = new Map([ + ['37_3004', { + DrawID: ['37'], + PlanningID: ['3004'], + IsMatch: [true], + MatchNr: ['17'], + From1: ['4007'], + From2: ['4008'], + WinnerTo: ['2002'], + LoserTo: ['4010'], + PlannedTime: [{ year: 2026, month: 4, day: 18, hour: 11, minute: 0 }], + }], + ['37_4007', { + DrawID: ['37'], + PlanningID: ['4007'], + IsMatch: [true], + MatchNr: ['7'], + LoserTo: ['4010'], + PlannedTime: [{ year: 2026, month: 4, day: 18, hour: 10, minute: 30 }], + }], + ['37_4008', { + DrawID: ['37'], + PlanningID: ['4008'], + IsMatch: [true], + MatchNr: ['8'], + LoserTo: ['4010'], + PlannedTime: [{ year: 2026, month: 4, day: 18, hour: 10, minute: 30 }], + }], + ]); + + const label = btp_sync._resolve_btp_dependency_link('37', '4010', '3005', [], planning_nodes); + + assert.strictEqual(label, 'Verlierer #17 - 2026-04-18 11:00'); + }); + + _it('resolves hidden predecessor nodes that only expose MatchNr without IsMatch', () => { + const planning_nodes = new Map([ + ['37_3013', { + DrawID: ['37'], + PlanningID: ['3013'], + MatchNr: ['18'], + From1: ['5017'], + From2: ['5018'], + WinnerTo: ['2013'], + LoserTo: ['2015'], + }], + ]); + + const label = btp_sync._resolve_btp_dependency_link('37', '3013', '2013', [], planning_nodes); + + assert.strictEqual(label, 'Gewinner #18'); + }); + + _it('derives time from the visible sibling match when a hidden placement node feeds the target', () => { + const planning_nodes = new Map([ + ['39_2003', { + DrawID: ['39'], + PlanningID: ['2003'], + MatchNr: ['49'], + From1: ['3001'], + From2: ['3002'], + WinnerTo: ['1003'], + LoserTo: ['1004'], + }], + ['39_2001', { + DrawID: ['39'], + PlanningID: ['2001'], + IsMatch: [true], + MatchNr: ['49'], + From1: ['3001'], + From2: ['3002'], + WinnerTo: ['1001'], + LoserTo: ['1002'], + PlannedTime: [{ year: 2026, month: 4, day: 19, hour: 14, minute: 0 }], + }], + ]); + + const label = btp_sync._resolve_btp_dependency_link('39', '2003', '1003', [], planning_nodes); + + assert.strictEqual(label, 'Gewinner #49 - 2026-04-19 14:00'); + }); + + _it('sanitizes end_points to be at least 1 and max_points to be at least end_points', () => { + const sanitized = btp_sync._sanitize_scoring_format({ + id: 99, + name: 'Broken', + numSets: 1, + score: 0, + isDefault: false, + setType: 1000, + lastSetType: 1000, + set_points: { + end_points: 0, + max_points: 0, + }, + last_set_points: { + end_points: -5, + max_points: 2, + }, + }); + + assert.strictEqual(sanitized.set_points.end_points, 1); + assert.strictEqual(sanitized.set_points.max_points, 1); + assert.strictEqual(sanitized.last_set_points.end_points, 1); + assert.strictEqual(sanitized.last_set_points.max_points, 2); + }); + + _it('normalizes different rules for the last set', () => { + const normalized = btp_sync._normalize_scoring_format({ + ID: ['12'], + Name: ['2x21+11'], + NumSets: ['3'], + SetType: ['0'], + LastSetType: ['304'], + Score: ['21'], + IsDefault: [false], + }); + + assert.deepStrictEqual(normalized.set_points, { + end_points: 21, + max_points: 30, + end_points_editable: false, + max_points_editable: false, + interval_at: 11, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + assert.deepStrictEqual(normalized.last_set_points, { + end_points: 11, + max_points: 15, + end_points_editable: false, + max_points_editable: false, + interval_at: 6, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }); + }); + + _it('provides a complete 3x21 fallback scoring format', () => { + const normalized = btp_sync._fallback_scoring_format(); + + assert.deepStrictEqual(normalized, { + id: null, + name: '3x21', + numSets: 3, + score: 21, + isDefault: false, + setType: 0, + lastSetType: 0, + set_points: { + end_points: 21, + max_points: 30, + end_points_editable: false, + max_points_editable: false, + interval_at: 11, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }, + last_set_points: { + end_points: 21, + max_points: 30, + end_points_editable: false, + max_points_editable: false, + interval_at: 11, + interval_duration_ms: 60000, + break_before_set_duration_ms: 120000, + }, + }); + }); + + _it('keeps the local court on finished matches when BTP no longer sends a court', () => { + const currentMatch = { + team1_won: true, + btp_winner: 1, + btp_needsync: false, + setup: { + court_id: 'court_7', + now_on_court: false, + state: 'finished', + teams: [{ players: [] }, { players: [] }], + }, + }; + const btpMatch = { + setup: { + now_on_court: false, + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + }, + }; + + const merged = btp_sync._merge_local_match_into_btp_match(currentMatch, structuredClone(btpMatch)); + + assert.strictEqual(merged.setup.state, 'finished'); + assert.strictEqual(merged.setup.court_id, 'court_7'); + }); + + _it('keeps locally edited timing values when BTP scoring formats are normalized again', () => { + const existing = { + id: 11, + name: 'Custom 1x17', + numSets: 1, + score: 17, + isDefault: false, + setType: 999, + lastSetType: 999, + set_points: { + end_points: 17, + max_points: 19, + end_points_editable: false, + max_points_editable: true, + defaults_from_score: true, + interval_at: 9, + interval_duration_ms: 45000, + break_before_set_duration_ms: 30000, + interval_enabled: false, + }, + last_set_points: { + end_points: 17, + max_points: 21, + end_points_editable: false, + max_points_editable: true, + defaults_from_score: true, + interval_at: 8, + interval_duration_ms: 40000, + break_before_set_duration_ms: 35000, + interval_enabled: true, + }, + }; + + const normalized = btp_sync._normalize_scoring_format({ + ID: ['11'], + Name: ['Custom 1x17'], + NumSets: ['1'], + SetType: ['999'], + LastSetType: ['999'], + Score: ['17'], + IsDefault: [false], + }); + + const merged = btp_sync._merge_local_scoring_format(existing, normalized); + + assert.strictEqual(merged.set_points.end_points, 17); + assert.strictEqual(merged.set_points.max_points, 19); + assert.strictEqual(merged.set_points.interval_at, 9); + assert.strictEqual(merged.set_points.interval_duration_ms, 45000); + assert.strictEqual(merged.set_points.break_before_set_duration_ms, 30000); + assert.strictEqual(merged.set_points.interval_enabled, false); + assert.strictEqual(merged.last_set_points.max_points, 21); + assert.strictEqual(merged.last_set_points.interval_at, 8); + assert.strictEqual(merged.last_set_points.interval_duration_ms, 40000); + assert.strictEqual(merged.last_set_points.break_before_set_duration_ms, 35000); + assert.strictEqual(merged.last_set_points.interval_enabled, true); + }); + + _it('ignores stale suppressed officials once the local match is no longer pending BTP sync', () => { + const currentMatch = { + btp_needsync: false, + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + suppressed_umpire_btp_id: 6, + }, + }; + const btpMatch = { + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + umpire: { + _id: 'default_btp_6', + btp_id: 6, + name: 'Michael G-Punkt', + }, + }, + }; + + const merged = btp_sync._merge_local_match_into_btp_match(currentMatch, structuredClone(btpMatch)); + + assert.ok(merged.setup.umpire); + assert.strictEqual(merged.setup.umpire.btp_id, 6); + assert.strictEqual(merged.setup.suppressed_umpire_btp_id, undefined); + }); + + _it('keeps suppressed officials hidden while a local match update is still pending sync', () => { + const currentMatch = { + btp_needsync: true, + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + suppressed_umpire_btp_id: 6, + }, + }; + const btpMatch = { + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + umpire: { + _id: 'default_btp_6', + btp_id: 6, + name: 'Michael G-Punkt', + }, + }, + }; + + const merged = btp_sync._merge_local_match_into_btp_match(currentMatch, structuredClone(btpMatch)); + + assert.strictEqual(merged.setup.umpire, undefined); + assert.strictEqual(merged.setup.suppressed_umpire_btp_id, 6); + }); + + _it('ignores stale suppressed service judges once the local match is no longer pending BTP sync', () => { + const currentMatch = { + btp_needsync: false, + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + suppressed_service_judge_btp_id: 7, + }, + }; + const btpMatch = { + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + service_judge: { + _id: 'default_btp_7', + btp_id: 7, + name: 'Service Judge', + }, + }, + }; + + const merged = btp_sync._merge_local_match_into_btp_match(currentMatch, structuredClone(btpMatch)); + + assert.ok(merged.setup.service_judge); + assert.strictEqual(merged.setup.service_judge.btp_id, 7); + assert.strictEqual(merged.setup.suppressed_service_judge_btp_id, undefined); + }); + + _it('does not restore role capability flags from match references during reconcile', (done) => { + const official = { + _id: 'o1', + tournament_key: 't1', + btp_id: 11, + firstname: 'Stefan', + surname: 'Schiedsrichter', + name: 'Stefan Schiedsrichter', + is_umpire: false, + is_service_judge: true, + is_planed_as_umpire: false, + is_planed_as_service_judge: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: 123, + umpire_pause: null, + service_judge_pause: null, + inactive_list: null, + }; + const match = { + _id: 'm1', + tournament_key: 't1', + setup: { + now_on_court: false, + umpire: { + _id: 'o1', + btp_id: 11, + firstname: 'Stefan', + surname: 'Schiedsrichter', + name: 'Stefan Schiedsrichter', + } + } + }; + const state = { + matches: [structuredClone(match)], + umpires: [structuredClone(official)] + }; + const app = { + db: { + tournaments: { + findOne(query, cb) { + cb(null, { key: 't1', btp_settings: { check_in_per_match: false } }); + } + }, + matches: { + find(query, cb) { + cb(null, state.matches); + } + }, + umpires: { + find(query, cb) { + cb(null, state.umpires); + }, + insert(doc, cb) { + state.umpires.push(doc); + cb(null, doc); + }, + update(query, update, options, cb) { + const idx = state.umpires.findIndex((u) => u._id === query._id); + state.umpires[idx] = { ...state.umpires[idx], ...update.$set }; + cb(null, 1, state.umpires[idx]); + } + } + } + }; + + btp_sync._reconcile_match_officials(app, 't1', (err) => { + assert.ifError(err); + assert.strictEqual(state.umpires[0].is_umpire, false); + assert.strictEqual(state.umpires[0].is_service_judge, true); + done(); + }); + }); + + _it('keeps a local umpire assignment only while the match update is still pending sync', () => { + const pendingCurrentMatch = { + btp_needsync: true, + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + umpire: { + _id: 'default_btp_6', + btp_id: 6, + name: 'Michael G-Punkt', + }, + }, + }; + const staleCurrentMatch = { + btp_needsync: false, + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + umpire: { + _id: 'default_btp_6', + btp_id: 6, + name: 'Michael G-Punkt', + }, + }, + }; + const btpMatchWithoutOfficial = { + setup: { + state: 'scheduled', + teams: [{ players: [] }, { players: [] }], + }, + }; + + const pendingMerged = btp_sync._merge_local_match_into_btp_match(pendingCurrentMatch, structuredClone(btpMatchWithoutOfficial)); + const staleMerged = btp_sync._merge_local_match_into_btp_match(staleCurrentMatch, structuredClone(btpMatchWithoutOfficial)); + + assert.ok(pendingMerged.setup.umpire); + assert.strictEqual(staleMerged.setup.umpire, undefined); + }); + + _it('drops stale local preparation state when the highlight is already cleared', () => { + const currentMatch = { + btp_needsync: false, + setup: { + state: 'preparation', + highlight: 0, + preparation_call_timestamp: 1775701859486, + teams: [{ players: [] }, { players: [] }], + }, + }; + const btpMatch = { + setup: { + state: 'scheduled', + highlight: 0, + teams: [{ players: [] }, { players: [] }], + }, + }; + + const merged = btp_sync._merge_local_match_into_btp_match(currentMatch, structuredClone(btpMatch)); + + assert.strictEqual(merged.setup.state, 'scheduled'); + assert.strictEqual(merged.setup.preparation_call_timestamp, undefined); + }); + + _it('clears stale planned and on-court flags when an official is no longer referenced by matches', () => { + const refState = btp_sync._build_official_reference_state([]); + const patch = btp_sync._compute_official_visibility_patch({ + _id: 'default_btp_6', + btp_id: 6, + is_umpire: true, + is_service_judge: true, + is_planed_as_umpire: true, + is_planed_as_service_judge: false, + umpire_on_court: 'default_1', + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: null, + }, refState); + + assert.strictEqual(patch.is_planed_as_umpire, false); + assert.strictEqual(patch.umpire_on_court, null); + assert.strictEqual(patch.umpire_wait != null, true); + assert.strictEqual(patch.service_judge_wait, null); + assert.strictEqual(patch.inactive_list, null); + }); + + _it('moves inactive officials back to wait when they are active-capable and no longer referenced', () => { + const refState = btp_sync._build_official_reference_state([]); + const patch = btp_sync._compute_official_visibility_patch({ + _id: 'default_btp_6', + btp_id: 6, + is_umpire: true, + is_service_judge: false, + is_planed_as_umpire: false, + is_planed_as_service_judge: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: 12345, + }, refState); + + assert.strictEqual(patch.umpire_wait != null, true); + assert.strictEqual(patch.service_judge_wait, null); + assert.strictEqual(patch.inactive_list, null); + }); + + _it('preserves planned flags when an official is still referenced by a scheduled match', () => { + const refState = btp_sync._build_official_reference_state([{ + setup: { + state: 'scheduled', + now_on_court: false, + umpire: { _id: 'default_btp_6', btp_id: 6 }, + }, + }]); + const patch = btp_sync._compute_official_visibility_patch({ + _id: 'default_btp_6', + btp_id: 6, + is_planed_as_umpire: true, + is_planed_as_service_judge: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: null, + }, refState); + + assert.deepStrictEqual(patch, {}); + }); + + _it('does not treat finished-match officials as still referenced for visibility', () => { + const refState = btp_sync._build_official_reference_state([{ + team1_won: true, + setup: { + state: 'finished', + now_on_court: false, + umpire: { _id: 'default_btp_5', btp_id: 5 }, + }, + }]); + const patch = btp_sync._compute_official_visibility_patch({ + _id: 'default_btp_5', + btp_id: 5, + is_umpire: true, + is_service_judge: true, + is_planed_as_umpire: false, + is_planed_as_service_judge: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: null, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: 12345, + }, refState); + + assert.strictEqual(patch.umpire_wait != null, true); + assert.strictEqual(patch.service_judge_wait, null); + assert.strictEqual(patch.inactive_list, null); + }); + + _it('reuses an existing official by canonical _id when btp_id is missing locally', () => { + const existing = { + _id: 'default_btp_6', + tournament_key: 'default', + btp_id: null, + name: 'Michael G-Punkt', + }; + + const found = btp_sync._find_existing_official_for_btp_import([existing], 'default', 6); + + assert.strictEqual(found, existing); + }); + }); diff --git a/test/test_bupws.js b/test/test_bupws.js new file mode 100644 index 0000000..2df9715 --- /dev/null +++ b/test/test_bupws.js @@ -0,0 +1,94 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const admin = require('../bts/admin.js'); +const bupws = require('../bts/bupws.js'); + +_describe('bupws', () => { + _it('clears the stored court match reference when the finished match still owns the court', (done) => { + const notifications = []; + const original_notify_change = admin.notify_change; + admin.notify_change = (_app, _tournament_key, ctype, payload) => { + notifications.push({ ctype, payload }); + }; + + const app = { + db: { + courts: { + update(query, update, options, cb) { + assert.deepStrictEqual(query, { tournament_key: 'default', _id: 'court-1' }); + assert.deepStrictEqual(update, { $set: { match_id: null } }); + assert.deepStrictEqual(options, { returnUpdatedDocs: true }); + cb(null, 1, { + _id: 'court-1', + is_active: true, + has_umpire: true, + has_service_judge: false, + match_id: null, + }); + } + } + } + }; + + bupws._clear_court_match_reference_after_finish( + app, + 'default', + { tournament_key: 'default', _id: 'court-1' }, + { _id: 'court-1', match_id: 'match-1', is_active: true, has_umpire: true, has_service_judge: false }, + 'match-1', + true, + (err, changed) => { + admin.notify_change = original_notify_change; + assert.ifError(err); + assert.strictEqual(changed, true); + assert.deepStrictEqual(notifications, [{ + ctype: 'court_changed', + payload: { + court_id: 'court-1', + is_active: true, + has_umpire: true, + has_service_judge: false, + match_id: null, + } + }]); + done(); + } + ); + }); + + _it('does not clear the court match reference for unrelated finishes', (done) => { + const original_notify_change = admin.notify_change; + admin.notify_change = () => { + throw new Error('notify_change must not be called'); + }; + + const app = { + db: { + courts: { + update() { + throw new Error('court update must not be called'); + } + } + } + }; + + bupws._clear_court_match_reference_after_finish( + app, + 'default', + { tournament_key: 'default', _id: 'court-1' }, + { _id: 'court-1', match_id: 'other-match' }, + 'match-1', + true, + (err, changed) => { + admin.notify_change = original_notify_change; + assert.ifError(err); + assert.strictEqual(changed, false); + done(); + } + ); + }); +}); diff --git a/test/test_change_helpers.js b/test/test_change_helpers.js new file mode 100644 index 0000000..6205d05 --- /dev/null +++ b/test/test_change_helpers.js @@ -0,0 +1,53 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const change_helpers = require('../static/js/change_helpers.js'); + +_describe('change_helpers', () => { + _it('updates officials view on umpires_changed', () => { + const deps = { + curt_ref: { umpires: [] }, + uiu_ref: { + qsEach(selector, cb) { + assert.strictEqual(selector, 'select[name="umpire_name"]'); + cb({ value: 'Stefan Schiedsrichter' }); + }, + qs(selector) { + assert.strictEqual(selector, '.umpire_container'); + return { id: 'umpire_container' }; + } + }, + cmatch_ref: { + calls: [], + render_umpire_options(select, value) { + this.calls.push({ select, value }); + } + }, + current_view_ref: 'show', + cumpires_ref: { + calls: [], + ui_status(container) { + this.calls.push(container); + } + }, + ctournament_ref: { + update_calls: 0, + update_officials() { + this.update_calls += 1; + } + } + }; + + const officials = [{ _id: 'u1', name: 'Stefan Schiedsrichter' }]; + change_helpers.apply_umpires_changed({ all_umpires: officials }, deps); + + assert.deepStrictEqual(deps.curt_ref.umpires, officials); + assert.strictEqual(deps.cmatch_ref.calls.length, 1); + assert.strictEqual(deps.cmatch_ref.calls[0].value, 'Stefan Schiedsrichter'); + assert.strictEqual(deps.cumpires_ref.calls.length, 1); + assert.strictEqual(deps.ctournament_ref.update_calls, 1); + }); +}); diff --git a/test/test_cmatch.js b/test/test_cmatch.js new file mode 100644 index 0000000..c127142 --- /dev/null +++ b/test/test_cmatch.js @@ -0,0 +1,457 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const {_describe, _it} = require('./tutils.js'); + +const match_scoring = require('../static/js/match_scoring'); + +function createFakeElement(tagName) { + return { + tagName, + children: [], + attributes: {}, + style: {}, + textContent: '', + className: '', + classList: { + _values: new Set(), + add(...names) { + names.filter(Boolean).forEach((name) => this._values.add(name)); + }, + remove(...names) { + names.forEach((name) => this._values.delete(name)); + }, + contains(name) { + return this._values.has(name); + }, + }, + setAttribute(name, value) { + this.attributes[name] = String(value); + }, + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; + }, + removeAttribute(name) { + delete this.attributes[name]; + }, + appendChild(child) { + this.children.push(child); + return child; + }, + addEventListener() {}, + }; +} + +function loadCmatchForUpdateCourtTest(tr, curt) { + const source = fs.readFileSync(path.join(__dirname, '..', 'static', 'js', 'cmatch.js'), 'utf8'); + const context = { + console, + setTimeout() { return 1; }, + clearTimeout() {}, + requestAnimationFrame() {}, + window: { + innerWidth: 1200, + addEventListener() {}, + getComputedStyle() { + return { + getPropertyValue() { + return '16px'; + } + }; + }, + }, + document: { + querySelectorAll() { return []; }, + }, + curt, + crouting: { register() {} }, + change: { default_handler(fn) { return fn; } }, + ci18n: (key) => key, + cerror: { net() {}, silent() {} }, + cflags: { render_flag_el() {} }, + cbts_utils: { cmp(a, b) { return a === b ? 0 : (a < b ? -1 : 1); } }, + ctabletoperator: { add_to_tabletoperator() {} }, + utils: { + find(list, predicate) { + return (list || []).find(predicate); + }, + remove(list, predicate) { + const idx = (list || []).findIndex(predicate); + if (idx === -1) { + return false; + } + list.splice(idx, 1); + return true; + }, + }, + uiu: { + qs(selector) { + if (selector === `tr[data-court_id="${tr.getAttribute('data-court_id')}"]`) { + return tr; + } + return null; + }, + qsEach() {}, + el(parent, tagName, attrsOrClass, text) { + const el = createFakeElement(tagName); + if (typeof attrsOrClass === 'string') { + el.className = attrsOrClass; + if (attrsOrClass) { + el.classList.add(...attrsOrClass.split(/\s+/).filter(Boolean)); + } + } else if (attrsOrClass && typeof attrsOrClass === 'object') { + Object.entries(attrsOrClass).forEach(([key, value]) => { + if (key === 'class') { + el.className = value; + el.classList.add(...String(value).split(/\s+/).filter(Boolean)); + } else { + el.setAttribute(key, value); + } + }); + } + if (text !== undefined) { + el.textContent = text; + } + parent.appendChild(el); + return el; + }, + }, + }; + vm.createContext(context); + vm.runInContext(source, context, { filename: 'cmatch.js' }); + return context.cmatch; +} + +_describe('cmatch', () => { + _it('detects match end for default 3x21 fallback', () => { + assert.strictEqual(match_scoring.is_match_over([[21, 10], [21, 18]], null), true); + assert.strictEqual(match_scoring.is_match_over([[21, 10], [18, 21]], null), false); + }); + + _it('detects match end for 1x21 scoring format', () => { + const scoringFormat = { + numSets: 1, + set_points: { end_points: 21, max_points: 30 }, + last_set_points: { end_points: 21, max_points: 30 }, + }; + + assert.strictEqual(match_scoring.is_match_over([[21, 19]], scoringFormat), true); + assert.strictEqual(match_scoring.is_match_over([[20, 19]], scoringFormat), false); + }); + + _it('uses last-set limits for deciding the final set', () => { + const scoringFormat = { + numSets: 3, + set_points: { end_points: 21, max_points: 30 }, + last_set_points: { end_points: 11, max_points: 15 }, + }; + + assert.strictEqual(match_scoring.is_match_over([[21, 18], [19, 21], [11, 9]], scoringFormat), true); + assert.strictEqual(match_scoring.is_match_over([[21, 18], [19, 21], [10, 9]], scoringFormat), false); + }); + + _it('clears a stale finished match from a court row when the court is updated', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + tr.setAttribute('data-match_id', 'match-1'); + tr.setAttribute('data-style', 'default'); + tr.innerHTML = 'stale'; + + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [{ + _id: 'match-1', + setup: { + is_match: true, + state: 'finished', + now_on_court: false, + }, + }], + }); + + cmatch.update_court({ _id: 'court-1', num: '1', is_active: true }); + + assert.strictEqual(tr.getAttribute('data-match_id'), null); + assert.strictEqual(tr.children.length, 3); + assert.strictEqual(tr.children[1].classList.contains('court_number'), true); + assert.strictEqual(tr.children[2].classList.contains('empty_element'), true); + }); + + _it('keeps the court match row when the referenced match is still on court', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + tr.setAttribute('data-match_id', 'match-1'); + tr.setAttribute('data-style', 'public'); + tr.innerHTML = 'old'; + + const cmatch = loadCmatchForUpdateCourtTest(tr, { + key: 'default', + btp_settings: { check_in_per_match: false }, + courts_by_id: { + 'court-1': { _id: 'court-1', num: '1', is_active: true } + }, + matches: [{ + _id: 'match-1', + setup: { + is_match: true, + state: 'oncourt', + now_on_court: true, + court_id: 'court-1', + teams: [ + { players: [{ btp_id: 1, name: 'Alice Example', firstname: 'Alice', lastname: 'Example', checked_in: true }] }, + { players: [{ btp_id: 2, name: 'Bob Example', firstname: 'Bob', lastname: 'Example', checked_in: true }] }, + ], + match_num: 12, + scheduled_date: '2026-04-15', + scheduled_time_str: '10:00', + }, + }], + }); + + cmatch.update_court({ _id: 'court-1', num: '1', is_active: true }); + + assert.strictEqual(tr.getAttribute('data-match_id'), 'match-1'); + assert.notStrictEqual(tr.children.length, 0); + }); + + _it('resolves CP-VF participant dependencies per slot instead of by shared candidate order', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [], + }); + + const target = { + _id: 'target', + btp_match_ids: [{ planning: 3005 }], + setup: { + links: { + from1: 4009, + from2: 4010, + } + } + }; + const predecessor = { + _id: 'pred-1', + btp_match_ids: [{ planning: 4009 }], + setup: { + is_match: true, + match_num: 18, + scheduled_date: '2026-04-18', + scheduled_time_str: '11:30', + links: { + winner_to: 3005, + loser_to: 3009, + } + } + }; + + assert.strictEqual( + cmatch._format_participant_dependency(target, 0, [predecessor]), + 'Winner #18 - 2026-04-18 11:30' + ); + assert.strictEqual( + cmatch._format_participant_dependency(target, 1, [predecessor]), + '???' + ); + }); + + _it('uses direct link labels before trying to infer a predecessor match', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [], + }); + + const target = { + _id: 'target', + btp_match_ids: [{ planning: 3005 }], + setup: { + links: { + from1: 4009, + from2: 4010, + from1_link: 'CP-VF (5/12)', + } + } + }; + + assert.strictEqual( + cmatch._format_participant_dependency(target, 0, []), + 'CP-VF (5/12)' + ); + }); + + _it('resolves a predecessor through a placeholder planning node for the correct slot', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [], + }); + + const target = { + _id: 'target', + btp_match_ids: [{ planning: 3005 }], + setup: { + links: { + from1: 4010, + from2: 4011, + } + } + }; + const placeholder = { + _id: 'placeholder-1', + btp_match_ids: [{ planning: 4010 }], + setup: { + is_match: false, + links: { + from1: 5017, + from2: 5018, + } + } + }; + const predecessor = { + _id: 'pred-1', + btp_match_ids: [{ planning: 4009 }], + setup: { + is_match: true, + match_num: 18, + scheduled_date: '2026-04-18', + scheduled_time_str: '11:30', + links: { + from1: 5017, + from2: 5018, + winner_to: 3005, + loser_to: 3009, + } + } + }; + + assert.strictEqual( + cmatch._format_participant_dependency(target, 0, [placeholder, predecessor]), + 'Winner #18 - 2026-04-18 11:30' + ); + assert.strictEqual( + cmatch._format_participant_dependency(target, 1, [placeholder, predecessor]), + '???' + ); + }); + + _it('falls back to incoming loser edges when a source planning has no local match node', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [], + }); + + const target = { + _id: 'target', + btp_match_ids: [{ planning: 3005 }], + setup: { + links: { + from1: 4009, + from2: 4010, + } + } + }; + const feederA = { + _id: 'r16-7', + btp_match_ids: [{ planning: 4007 }], + setup: { + is_match: true, + match_num: 7, + scheduled_date: '2026-04-18', + scheduled_time_str: '09:00', + links: { + loser_to: 4010, + } + } + }; + const feederB = { + _id: 'r16-8', + btp_match_ids: [{ planning: 4008 }], + setup: { + is_match: true, + match_num: 8, + scheduled_date: '2026-04-18', + scheduled_time_str: '09:30', + links: { + loser_to: 4010, + } + } + }; + + assert.strictEqual( + cmatch._format_participant_dependency(target, 1, [feederB, feederA]), + 'Loser #7 / #8' + ); + }); + + _it('prefers the visible consolidation match number for hidden loser slots', () => { + const tr = createFakeElement('tr'); + tr.setAttribute('data-court_id', 'court-1'); + const cmatch = loadCmatchForUpdateCourtTest(tr, { + matches: [], + }); + + const target = { + _id: 'target', + btp_match_ids: [{ planning: 3005 }], + setup: { + links: { + from1: 4009, + from2: 4010, + } + } + }; + const feederA = { + _id: 'r16-7', + btp_match_ids: [{ planning: 4007 }], + setup: { + is_match: true, + match_num: 7, + scheduled_date: '2026-04-18', + scheduled_time_str: '09:00', + links: { + loser_to: 4010, + } + } + }; + const feederB = { + _id: 'r16-8', + btp_match_ids: [{ planning: 4008 }], + setup: { + is_match: true, + match_num: 8, + scheduled_date: '2026-04-18', + scheduled_time_str: '09:30', + links: { + loser_to: 4010, + } + } + }; + const visibleConsolidation = { + _id: 'vf-17', + btp_match_ids: [{ planning: 3004 }], + setup: { + is_match: true, + match_num: 17, + scheduled_date: '2026-04-18', + scheduled_time_str: '11:00', + links: { + from1: 4007, + from2: 4008, + winner_to: 2002, + loser_to: 2004, + } + } + }; + + assert.strictEqual( + cmatch._format_participant_dependency(target, 1, [feederA, feederB, visibleConsolidation]), + 'Loser #17 - 2026-04-18 11:00' + ); + }); +}); diff --git a/test/test_cmatch_official_select.js b/test/test_cmatch_official_select.js new file mode 100644 index 0000000..e36d91d --- /dev/null +++ b/test/test_cmatch_official_select.js @@ -0,0 +1,118 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const cmatch_official_select_helpers = require('../static/js/cmatch_official_select_helpers'); + +function build_entries(tournament, is_service_judge, show_all_officials) { + return cmatch_official_select_helpers.build_official_select_entries( + tournament, + is_service_judge, + show_all_officials, + { + ci18n_fn: (key) => key, + natcmp_fn: (a, b) => String(a).localeCompare(String(b), 'en'), + } + ); +} + +_describe('cmatch official select entries', () => { + _it('groups show-all umpire entries in the agreed order', () => { + const tournament = { + umpires: [ + { _id: 'u1', name: 'Wartet U', umpire_wait: 10 }, + { _id: 'u2', name: 'Wartet SJ', service_judge_wait: 20 }, + { _id: 'u3', name: 'Pause U', umpire_pause: 30 }, + { _id: 'u4', name: 'Pause SJ', service_judge_pause: 40 }, + { _id: 'u5', name: 'Assigned U' }, + { _id: 'u6', name: 'Assigned SJ' }, + { _id: 'u7', name: 'Inactive U', inactive_list: 50, is_umpire: true, is_service_judge: false }, + { _id: 'u8', name: 'Prep U' }, + { _id: 'u9', name: 'Court U', umpire_on_court: 'c1' }, + ], + matches: [ + { setup: { state: 'ready', match_num: 7, umpire: { _id: 'u5', name: 'Assigned U' }, service_judge: { _id: 'u6', name: 'Assigned SJ' } } }, + { setup: { state: 'preparation', match_num: 8, preparation_call_timestamp: 1, umpire: { _id: 'u8', name: 'Prep U' } } }, + ] + }; + + const entries = build_entries(tournament, false, true); + const labels = entries.map((entry) => entry.label); + + assert.deepStrictEqual(labels, [ + 'Wartet U', + '--- Waiting list service judge ---', + 'Wartet SJ (Service judge)', + '--- Currently on break: Umpire ---', + 'Pause U', + '--- Currently on break: Service judge ---', + 'Pause SJ (Service judge)', + '--- Assigned to a match ---', + 'Assigned U', + '--- Assigned to a match ---', + 'Assigned SJ (Service judge)', + '--- Not available ---', + 'Inactive U', + '--- In preparation ---', + 'Prep U', + '--- On court ---', + 'Court U' + ]); + }); + + _it('omits a separator before the first wait-list group in restricted mode', () => { + const tournament = { + umpires: [ + { _id: 'u1', name: 'Alpha', umpire_wait: 10 }, + { _id: 'u2', name: 'Beta', service_judge_wait: 20 }, + ], + matches: [] + }; + + const entries = build_entries(tournament, false, false); + const labels = entries.map((entry) => entry.label); + + assert.deepStrictEqual(labels, [ + 'Alpha', + '--- Waiting list service judge ---', + 'Beta (Service judge)', + ]); + }); + + _it('does not render assigned or preparation headings when those groups are empty', () => { + const tournament = { + umpires: [ + { _id: 'u1', name: 'Wartet U', umpire_wait: 10 }, + { _id: 'u2', name: 'Court U', umpire_on_court: 'c1' }, + ], + matches: [] + }; + + const entries = build_entries(tournament, false, true); + const labels = entries.map((entry) => entry.label); + + assert.strictEqual(labels.includes('--- Assigned to a match ---'), false); + assert.strictEqual(labels.includes('--- In preparation ---'), false); + }); + + _it('renders assigned and preparation headings only when the corresponding groups have entries', () => { + const tournament = { + umpires: [ + { _id: 'u1', name: 'Assigned U' }, + { _id: 'u2', name: 'Prep U' }, + ], + matches: [ + { setup: { state: 'ready', match_num: 7, umpire: { _id: 'u1', name: 'Assigned U' } } }, + { setup: { state: 'preparation', match_num: 8, preparation_call_timestamp: 1, umpire: { _id: 'u2', name: 'Prep U' } } }, + ] + }; + + const entries = build_entries(tournament, false, true); + const labels = entries.map((entry) => entry.label); + + assert.strictEqual(labels.includes('--- Assigned to a match ---'), true); + assert.strictEqual(labels.includes('--- In preparation ---'), true); + }); +}); diff --git a/test/test_match_automation.js b/test/test_match_automation.js new file mode 100644 index 0000000..db22416 --- /dev/null +++ b/test/test_match_automation.js @@ -0,0 +1,1786 @@ +'use strict'; + +const assert = require('assert'); + +const {_describe, _it} = require('./tutils.js'); + +const match_automation = require('../bts/match_automation.js'); + +function make_scoring_format(overrides = {}) { + return { + numSets: 3, + set_points: { + end_points: 21, + max_points: 30, + }, + last_set_points: { + end_points: 21, + max_points: 30, + }, + ...overrides, + }; +} + +function make_match({ network_score, now_on_court = true, team1_won = null } = {}) { + return { + network_score, + team1_won, + setup: { + now_on_court, + }, + }; +} + +function make_preparation_match(overrides = {}) { + const setup_overrides = overrides.setup || {}; + const match = { + _id: overrides._id || 'm1', + match_order: overrides.match_order, + team1_won: overrides.team1_won ?? null, + setup: { + state: 'scheduled', + is_match: true, + incomplete: false, + match_num: 1, + scheduled_date: '2026-04-07', + scheduled_time_str: '10:00', + location_id: null, + court_id: null, + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [{ _id: 'p2' }] }, + ], + ...setup_overrides, + }, + }; + if (overrides.btp_match_ids) { + match.btp_match_ids = overrides.btp_match_ids; + } + if (overrides.btp_player_ids) { + match.btp_player_ids = overrides.btp_player_ids; + } + return match; +} + +function make_court(overrides = {}) { + return { + _id: overrides._id || 'c1', + location_id: overrides.location_id || 'l1', + is_active: overrides.is_active !== false, + match_id: overrides.match_id ?? null, + ...overrides, + }; +} + +function make_location(overrides = {}) { + return { + _id: overrides._id || 'l1', + name: overrides.name || 'Location 1', + ...overrides, + }; +} + +function make_tournament(overrides = {}) { + return { + key: overrides.key || 't1', + courts: overrides.courts || [], + matches: overrides.matches || [], + locations: overrides.locations || [], + umpires: overrides.umpires || [], + btp_settings: overrides.btp_settings || {}, + ...overrides, + }; +} + +function make_official(overrides = {}) { + return { + _id: overrides._id || 'o1', + umpire_wait: overrides.umpire_wait ?? null, + service_judge_wait: overrides.service_judge_wait ?? null, + ...overrides, + }; +} + +function make_realistic_location_fixture() { + const location = make_location({ _id: 'l-hall', name: 'BTS Halle' }); + const courts = [ + make_court({ _id: 'c-free', location_id: location._id, is_active: true, match_id: null }), + make_court({ _id: 'c-on', location_id: location._id, is_active: true, match_id: 'm-live' }), + make_court({ _id: 'c-off', location_id: location._id, is_active: false, match_id: 'm-disabled-live' }), + ]; + const matches = [ + make_preparation_match({ + _id: 'm-live', + setup: { + state: 'oncourt', + now_on_court: true, + court_id: 'c-on', + match_num: 10, + scheduled_time_str: '10:00', + needs_preparation_successor: true, + }, + }), + make_preparation_match({ + _id: 'm-disabled-live', + setup: { + state: 'oncourt', + now_on_court: true, + court_id: 'c-off', + match_num: 11, + scheduled_time_str: '10:00', + needs_preparation_successor: true, + }, + }), + make_preparation_match({ + _id: 'm-prepared', + setup: { + state: 'preparation', + location_id: location._id, + match_num: 12, + scheduled_time_str: '10:15', + }, + }), + make_preparation_match({ + _id: 'm-eligible-1', + match_order: 1, + setup: { + match_num: 13, + location_id: location._id, + scheduled_time_str: '10:30', + }, + }), + make_preparation_match({ + _id: 'm-eligible-2', + match_order: 2, + setup: { + match_num: 14, + location_id: location._id, + scheduled_time_str: '10:45', + }, + }), + ]; + + return make_tournament({ + call_preparation_matches_automatically_enabled: true, + locations: [location], + courts, + matches, + }); +} + +_describe('match automation', () => { + _it('returns false when no side leads in the current set', () => { + const match = make_match({ + network_score: [[10, 10]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 5), + false + ); + }); + + _it('returns false when the leader cannot finish the match within n rallies', () => { + const match = make_match({ + network_score: [[21, 18], [9, 7]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 11), + false + ); + }); + + _it('returns true when the leader can finish the match within n rallies', () => { + const match = make_match({ + network_score: [[21, 18], [10, 7]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 11), + true + ); + }); + + _it('allows rallies to carry over into the next set', () => { + const match = make_match({ + network_score: [[18, 15]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 25), + true + ); + }); + + _it('returns false when carried-over rallies still do not finish the match', () => { + const match = make_match({ + network_score: [[18, 15]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 20), + false + ); + }); + + _it('returns false in deuce when no side leads', () => { + const match = make_match({ + network_score: [[21, 18], [20, 20]], + }); + + assert.strictEqual( + match_automation.can_leader_finish_match_within_rallies(match, make_scoring_format(), 1), + false + ); + }); + + _it('returns zero preparation need when no successors and no free courts exist', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + }, + }, + ], + }, 'l1'); + + assert.deepStrictEqual(result, { + location_id: 'l1', + active_court_count: 1, + successor_need_count: 0, + free_court_count: 0, + required_preparation_count: 0, + }); + }); + + _it('tracks a free active court separately from preparation successor need', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: null }, + ], + matches: [], + }, 'l1'); + + assert.strictEqual(result.successor_need_count, 0); + assert.strictEqual(result.free_court_count, 1); + assert.strictEqual(result.required_preparation_count, 1); + }); + + _it('counts a triggered on-court match towards preparation need', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.successor_need_count, 1); + assert.strictEqual(result.free_court_count, 0); + assert.strictEqual(result.required_preparation_count, 1); + }); + + _it('does not count successor need on inactive courts', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: false, match_id: 'm1' }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.active_court_count, 0); + assert.strictEqual(result.successor_need_count, 0); + assert.strictEqual(result.free_court_count, 0); + assert.strictEqual(result.required_preparation_count, 0); + }); + + _it('uses only successor need as required preparation count', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + { _id: 'c2', location_id: 'l1', is_active: true, match_id: 'm2' }, + { _id: 'c3', location_id: 'l1', is_active: true, match_id: null }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + { + _id: 'm2', + setup: { + now_on_court: true, + court_id: 'c2', + needs_preparation_successor: true, + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.successor_need_count, 2); + assert.strictEqual(result.free_court_count, 1); + assert.strictEqual(result.required_preparation_count, 3); + }); + + _it('ignores inactive or occupied courts', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: false, match_id: null }, + { _id: 'c2', location_id: 'l1', is_active: true, match_id: 'm2' }, + { _id: 'c3', location_id: 'l1', is_active: true, match_id: null }, + ], + matches: [ + { + _id: 'm2', + setup: { + now_on_court: true, + court_id: 'c2', + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.free_court_count, 1); + assert.strictEqual(result.required_preparation_count, 1); + }); + + _it('treats stale court match references as free when no match is actually on court', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'old-match' }, + ], + matches: [ + { + _id: 'old-match', + setup: { + now_on_court: false, + court_id: 'c1', + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.free_court_count, 1); + assert.strictEqual(result.required_preparation_count, 1); + }); + + _it('only counts matches and courts of the requested location', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + { _id: 'c2', location_id: 'l1', is_active: true, match_id: null }, + { _id: 'c3', location_id: 'l2', is_active: true, match_id: null }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + { + _id: 'm2', + setup: { + now_on_court: true, + court_id: 'c3', + needs_preparation_successor: true, + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.successor_need_count, 1); + assert.strictEqual(result.free_court_count, 1); + assert.strictEqual(result.required_preparation_count, 2); + }); + + _it('caps required preparation count at the number of active courts', () => { + const result = match_automation.calculate_location_preparation_need({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + { + _id: 'm2', + setup: { + state: 'preparation', + location_id: 'l1', + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.active_court_count, 1); + assert.strictEqual(result.successor_need_count, 1); + assert.strictEqual(result.free_court_count, 0); + assert.strictEqual(result.required_preparation_count, 1); + }); + + _it('uses the default rally threshold for preparation successor state', () => { + const match = make_match({ + network_score: [[21, 18], [10, 7]], + }); + match.setup.scoring_format = make_scoring_format(); + + const result = match_automation.calculate_preparation_successor_state(match, {}); + + assert.strictEqual(result.rally_count, 11); + assert.strictEqual(result.needs_preparation_successor, true); + assert.ok(Number.isFinite(result.needs_preparation_successor_ts)); + }); + + _it('preserves an existing preparation successor timestamp while the flag stays active', () => { + const match = make_match({ + network_score: [[21, 18], [10, 7]], + }); + match.setup.scoring_format = make_scoring_format(); + match.setup.needs_preparation_successor_ts = 12345; + + const result = match_automation.calculate_preparation_successor_state(match, { + preparation_successor_rally_count: 11, + }); + + assert.strictEqual(result.needs_preparation_successor, true); + assert.strictEqual(result.needs_preparation_successor_ts, 12345); + }); + + _it('subtracts already prepared matches for the same location', () => { + const result = match_automation.calculate_location_preparation_status({ + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: 'm1' }, + { _id: 'c2', location_id: 'l1', is_active: true, match_id: null }, + ], + matches: [ + { + _id: 'm1', + setup: { + now_on_court: true, + court_id: 'c1', + needs_preparation_successor: true, + }, + }, + { + _id: 'm2', + setup: { + state: 'preparation', + location_id: 'l1', + }, + }, + ], + }, 'l1'); + + assert.strictEqual(result.required_preparation_count, 2); + assert.strictEqual(result.current_preparation_count, 1); + assert.strictEqual(result.missing_preparation_count, 1); + }); + + _it('accepts a scheduled match explicitly assigned to the location', () => { + const tournament = { courts: [], matches: [] }; + const match = make_preparation_match({ + setup: { + location_id: 'l1', + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('accepts a scheduled match via a court assigned to the location', () => { + const tournament = { + courts: [ + { _id: 'c1', location_id: 'l1' }, + ], + matches: [], + }; + const match = make_preparation_match({ + setup: { + court_id: 'c1', + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('accepts an unassigned match for any location', () => { + const tournament = { courts: [], matches: [] }; + const match = make_preparation_match(); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('rejects a match assigned to another location', () => { + const tournament = { courts: [], matches: [] }; + const match = make_preparation_match({ + setup: { + location_id: 'l2', + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + + _it('rejects a match whose court belongs to another location', () => { + const tournament = { + courts: [ + { _id: 'c2', location_id: 'l2' }, + ], + matches: [], + }; + const match = make_preparation_match({ + setup: { + court_id: 'c2', + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + + _it('rejects matches that are not clean scheduled candidates', () => { + const tournament = { courts: [], matches: [] }; + const cases = [ + make_preparation_match({ setup: { state: 'preparation' } }), + make_preparation_match({ setup: { state: 'oncourt' } }), + make_preparation_match({ setup: { state: 'blocked' } }), + make_preparation_match({ setup: { state: 'finished' } }), + make_preparation_match({ setup: { state: 'draft' } }), + make_preparation_match({ setup: { is_match: false } }), + make_preparation_match({ setup: { incomplete: true } }), + make_preparation_match({ team1_won: true }), + make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + }, + }), + ]; + + cases.forEach((match) => { + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + }); + + _it('respects the optional time limit before scheduled time', () => { + const tournament = { + preparation_call_time_limit_before_scheduled_enabled: true, + preparation_call_time_limit_before_scheduled_minutes: 15, + courts: [], + matches: [], + }; + const match = make_preparation_match({ + setup: { + scheduled_date: '2026-04-07', + scheduled_time_str: '10:00', + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament, { + now_ts: Date.parse('2026-04-07T09:44:00'), + }), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament, { + now_ts: Date.parse('2026-04-07T09:45:00'), + }), + true + ); + }); + + _it('rejects matches without usable scheduling when the time limit is enabled', () => { + const tournament = { + preparation_call_time_limit_before_scheduled_enabled: true, + preparation_call_time_limit_before_scheduled_minutes: 15, + courts: [], + matches: [], + }; + const match = make_preparation_match({ + setup: { + scheduled_time_str: null, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + + _it('respects the optional player pause rule', () => { + const tournament = { + preparation_call_player_pause_expired_enabled: true, + btp_settings: { + pause_duration_ms: 10 * 60 * 1000, + }, + courts: [], + matches: [], + }; + const match = make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1', last_time_on_court_ts: Date.parse('2026-04-07T09:55:01') }] }, + { players: [{ _id: 'p2', last_time_on_court_ts: Date.parse('2026-04-07T09:40:00') }] }, + ], + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament, { + now_ts: Date.parse('2026-04-07T10:00:00'), + }), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament, { + now_ts: Date.parse('2026-04-07T10:06:00'), + }), + true + ); + }); + + _it('treats players currently on court or on tablet as not pause-cleared when the rule is enabled', () => { + const tournament = { + preparation_call_player_pause_expired_enabled: true, + btp_settings: { + pause_duration_ms: 10 * 60 * 1000, + }, + courts: [], + matches: [], + }; + const now = Date.parse('2026-04-07T10:30:00'); + + const playing_match = make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1', now_playing_on_court: 'c1' }] }, + { players: [{ _id: 'p2' }] }, + ], + }, + }); + const tablet_match = make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1', now_tablet_on_court: 'c1' }] }, + { players: [{ _id: 'p2' }] }, + ], + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(playing_match, 'l1', tournament, { now_ts: now }), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(tablet_match, 'l1', tournament, { now_ts: now }), + false + ); + }); + + _it('does not enforce player pause timing when the rule is disabled', () => { + const tournament = { + preparation_call_player_pause_expired_enabled: false, + btp_settings: { + pause_duration_ms: 10 * 60 * 1000, + }, + courts: [], + matches: [], + }; + const match = make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1', last_time_on_court_ts: Date.parse('2026-04-07T09:59:30') }] }, + { players: [{ _id: 'p2' }] }, + ], + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament, { + now_ts: Date.parse('2026-04-07T10:00:00'), + }), + true + ); + }); + + _it('requires matches to already be in preparation for the configured minimum time on court-call automation', () => { + const court = make_court({ _id: 'c1', location_id: 'l1', is_active: true }); + const tournament = make_tournament({ + courts: [court], + matches: [], + call_on_court_only_preparation_enabled: true, + call_on_court_only_preparation_minutes: 5, + }); + const scheduled_match = make_preparation_match({ + _id: 'scheduled', + setup: { + state: 'scheduled', + location_id: 'l1', + }, + }); + const recent_preparation_match = make_preparation_match({ + _id: 'prep-recent', + setup: { + state: 'preparation', + location_id: 'l1', + preparation_call_timestamp: Date.parse('2026-04-07T09:57:00'), + }, + }); + const old_preparation_match = make_preparation_match({ + _id: 'prep-old', + setup: { + state: 'preparation', + location_id: 'l1', + preparation_call_timestamp: Date.parse('2026-04-07T09:54:00'), + }, + }); + tournament.matches = [scheduled_match, recent_preparation_match, old_preparation_match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(scheduled_match, 'c1', tournament, { + now_ts: Date.parse('2026-04-07T10:00:00'), + }), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(recent_preparation_match, 'c1', tournament, { + now_ts: Date.parse('2026-04-07T10:00:00'), + }), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(old_preparation_match, 'c1', tournament, { + now_ts: Date.parse('2026-04-07T10:00:00'), + }), + true + ); + }); + + _it('ignores earlier scheduled matches as frontier blockers when only preparation matches may be called', () => { + const court = make_court({ _id: 'c1', location_id: 'l1', is_active: true }); + const scheduled_match = make_preparation_match({ + _id: 'scheduled-earlier', + match_order: 1, + setup: { + state: 'scheduled', + location_id: 'l1', + match_num: 10, + scheduled_time_str: '10:00', + }, + }); + const preparation_match = make_preparation_match({ + _id: 'prepared-later', + match_order: 2, + setup: { + state: 'preparation', + location_id: 'l1', + match_num: 11, + scheduled_time_str: '10:15', + preparation_call_timestamp: Date.parse('2026-04-07T09:59:00'), + }, + }); + const tournament = make_tournament({ + courts: [court], + matches: [scheduled_match, preparation_match], + call_on_court_only_preparation_enabled: true, + call_on_court_only_preparation_minutes: 0, + }); + + const candidates = match_automation.find_call_on_court_candidates(tournament, 'c1', { + now_ts: Date.parse('2026-04-07T10:00:00'), + }); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['prepared-later']); + }); + + _it('prefers the oldest preparation call when multiple prepared matches are eligible for on-court calls', () => { + const court = make_court({ _id: 'c1', location_id: 'l1', is_active: true }); + const newer_preparation_match = make_preparation_match({ + _id: 'prepared-newer', + match_order: 2, + setup: { + state: 'preparation', + location_id: 'l1', + match_num: 11, + scheduled_time_str: '10:15', + preparation_call_timestamp: Date.parse('2026-04-07T09:58:00'), + }, + }); + const older_preparation_match = make_preparation_match({ + _id: 'prepared-older', + match_order: 3, + setup: { + state: 'preparation', + location_id: 'l1', + match_num: 12, + scheduled_time_str: '10:30', + preparation_call_timestamp: Date.parse('2026-04-07T09:52:00'), + }, + }); + const tournament = make_tournament({ + courts: [court], + matches: [newer_preparation_match, older_preparation_match], + call_on_court_only_preparation_enabled: true, + call_on_court_only_preparation_minutes: 0, + }); + + const candidates = match_automation.find_call_on_court_candidates(tournament, 'c1', { + now_ts: Date.parse('2026-04-07T10:00:00'), + }); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['prepared-older', 'prepared-newer']); + }); + + _it('requires all players to be checked in when the on-court participant rule is set to checked_in', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true })], + call_on_court_participant_readiness_mode: 'checked_in', + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + teams: [ + { players: [{ _id: 'p1', checked_in: true }] }, + { players: [{ _id: 'p2', checked_in: false }] }, + ], + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + false + ); + + match.setup.teams[1].players[0].checked_in = true; + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + true + ); + }); + + _it('requires assigned technical officials to be checked in when the on-court official rule is set to checked_in', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true })], + official_rotation_mode: 'umpire_and_service_judge', + call_on_court_technical_officials_mode: 'checked_in', + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + umpire: { _id: 'u1', checked_in: true }, + service_judge: { _id: 'u2', checked_in: false }, + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + false + ); + + match.setup.service_judge.checked_in = true; + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + true + ); + }); + + _it('requires technical officials to be available when the on-court official rule is set to available', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true })], + official_rotation_mode: 'umpire_only', + technical_official_auto_assignment_mode: 'when_available', + call_on_court_technical_officials_mode: 'available', + umpires: [], + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + false + ); + + tournament.umpires = [make_official({ _id: 'u1', umpire_wait: 111, checked_in: true })]; + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + true + ); + }); + + _it('requires enough official space on the target court when availability is checked for on-match-call assignment', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true, has_umpire: false })], + official_rotation_mode: 'umpire_only', + technical_official_auto_assignment_mode: 'on_match_call_if_possible', + call_on_court_technical_officials_mode: 'available', + umpires: [make_official({ _id: 'u1', umpire_wait: 111, checked_in: true })], + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + false + ); + }); + + _it('allows on-court call with only an umpire when the target court has no service judge space', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true, has_umpire: true, has_service_judge: false })], + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_match_call_if_possible', + call_on_court_technical_officials_mode: 'available', + umpires: [make_official({ _id: 'u1', umpire_wait: 111, checked_in: true })], + }); + const match = make_preparation_match({ + setup: { + state: 'preparation', + location_id: 'l1', + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + true + ); + }); + + _it('does not require a service judge to be checked in on a court without service judge space', () => { + const tournament = make_tournament({ + courts: [make_court({ _id: 'c1', location_id: 'l1', is_active: true, has_umpire: true, has_service_judge: false })], + official_rotation_mode: 'umpire_and_service_judge', + call_on_court_technical_officials_mode: 'checked_in', + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + umpire: { _id: 'u1', checked_in: true }, + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + true + ); + }); + + _it('can require enough court space for already assigned technical officials', () => { + const tournament = make_tournament({ + courts: [ + make_court({ _id: 'c1', location_id: 'l1', is_active: true, has_umpire: true, has_service_judge: false }), + make_court({ _id: 'c2', location_id: 'l1', is_active: true, has_umpire: true, has_service_judge: true }), + ], + call_on_court_require_official_space_enabled: true, + }); + const match = make_preparation_match({ + setup: { + location_id: 'l1', + umpire: { _id: 'u1', checked_in: true }, + service_judge: { _id: 'u2', checked_in: true }, + }, + }); + tournament.matches = [match]; + + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c1', tournament), + false + ); + assert.strictEqual( + match_automation.is_match_eligible_for_on_court_call(match, 'c2', tournament), + true + ); + }); + + _it('requires a waiting umpire when the technical officials rule is enabled in umpire_only mode', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_only', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [], + }); + const match = make_preparation_match(); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + + tournament.umpires = [ + make_official({ _id: 'u1', umpire_wait: 111 }), + ]; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('requires distinct waiting umpire and service judge capacity in full rotation mode', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [ + make_official({ _id: 'dual-only', umpire_wait: 111, service_judge_wait: 222 }), + ], + }); + const match = make_preparation_match(); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + + tournament.umpires = [ + make_official({ _id: 'u1', umpire_wait: 111 }), + make_official({ _id: 's1', service_judge_wait: 222 }), + ]; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('accepts matches that already have the required technical officials assigned', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [], + }); + const match = make_preparation_match({ + setup: { + umpire: { _id: 'u1' }, + service_judge: { _id: 's1' }, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('accepts matches with one assigned technical official when the missing role is still available', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [ + make_official({ _id: 's1', service_judge_wait: 222 }), + ], + }); + const match = make_preparation_match({ + setup: { + umpire: { _id: 'u1' }, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('does not count an already assigned official as available for the missing second role', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [ + make_official({ _id: 'dual1', service_judge_wait: 222 }), + ], + }); + const match = make_preparation_match({ + setup: { + umpire: { _id: 'dual1' }, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + + _it('can ignore the technical officials availability rule when searching likely future candidates', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: true, + official_rotation_mode: 'umpire_and_service_judge', + technical_official_auto_assignment_mode: 'on_preparation_call', + umpires: [ + make_official({ _id: 'u1', umpire_wait: 111 }), + ], + matches: [ + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '10:00', + }, + }), + ], + }); + + assert.deepStrictEqual( + match_automation.find_global_preparation_candidates(tournament).map((match) => match._id), + [] + ); + + assert.deepStrictEqual( + match_automation.find_global_preparation_candidates(tournament, { + ignore_technical_officials_available_rule: true, + }).map((match) => match._id), + ['m1'] + ); + }); + + _it('ignores the technical officials rule when it is disabled', () => { + const tournament = make_tournament({ + preparation_call_technical_officials_available_enabled: false, + official_rotation_mode: 'umpire_and_service_judge', + umpires: [], + }); + const match = make_preparation_match(); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('rejects matches with an unfinished direct predecessor', () => { + const predecessor = make_preparation_match({ + _id: 'm-pre', + btp_match_ids: [{ planning: 100 }], + setup: { + match_num: 1, + scheduled_time_str: '09:30', + }, + }); + const match = make_preparation_match({ + _id: 'm-target', + setup: { + match_num: 2, + scheduled_time_str: '10:00', + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { + from1: 100, + }, + }, + }); + const tournament = { + courts: [], + matches: [predecessor, match], + }; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + + predecessor.team1_won = true; + match.setup.teams[1].players = [{ _id: 'p2' }]; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('rejects matches with unresolved predecessor references when participants are not yet complete', () => { + const tournament = { courts: [], matches: [] }; + const match = make_preparation_match({ + setup: { + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { + from1: 999, + }, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + false + ); + }); + + _it('does not reject complete matches only because raw predecessor links exist', () => { + const tournament = { courts: [], matches: [] }; + const match = make_preparation_match({ + setup: { + links: { + from1: 999, + }, + }, + }); + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(match, 'l1', tournament), + true + ); + }); + + _it('rejects matches whose players are still in an earlier unfinished match', () => { + const earlier_match = make_preparation_match({ + _id: 'm-earlier', + setup: { + match_num: 1, + scheduled_time_str: '09:30', + teams: [ + { players: [{ _id: 'p1', btp_id: 10 }] }, + { players: [{ _id: 'p2', btp_id: 20 }] }, + ], + }, + }); + const later_match = make_preparation_match({ + _id: 'm-later', + setup: { + match_num: 2, + scheduled_time_str: '10:00', + teams: [ + { players: [{ _id: 'p1', btp_id: 10 }] }, + { players: [{ _id: 'p3', btp_id: 30 }] }, + ], + }, + }); + const tournament = { + courts: [], + matches: [earlier_match, later_match], + }; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(later_match, 'l1', tournament), + false + ); + + earlier_match.team1_won = true; + + assert.strictEqual( + match_automation.is_match_eligible_for_preparation(later_match, 'l1', tournament), + true + ); + }); + + _it('limits candidates by block distance from the first unusable match', () => { + const tournament = { + preparation_call_block_ahead_limit_enabled: true, + preparation_call_block_ahead_limit: 1, + courts: [], + matches: [ + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:00', + event_name: 'HE', + phase_block_key: 'G1', + }, + }), + make_preparation_match({ + _id: 'm2', + setup: { + match_num: 2, + scheduled_time_str: '09:05', + event_name: 'HE', + phase_block_key: 'G1', + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { from1: 999 }, + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_time_str: '09:10', + event_name: 'HE', + phase_block_key: 'G1', + }, + }), + make_preparation_match({ + _id: 'm4', + setup: { + match_num: 4, + scheduled_time_str: '09:15', + event_name: 'HE', + phase_block_key: 'G2', + }, + }), + make_preparation_match({ + _id: 'm5', + setup: { + match_num: 5, + scheduled_time_str: '09:20', + event_name: 'HE', + phase_block_key: 'VF', + }, + }), + ], + }; + + const candidates = match_automation.find_location_preparation_candidates(tournament, 'l1'); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm3', 'm4']); + }); + + _it('limits candidates by scheduled time distance from the first unusable match', () => { + const tournament = { + preparation_call_time_ahead_of_frontier_enabled: true, + preparation_call_time_ahead_of_frontier_minutes: 30, + courts: [], + matches: [ + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:00', + }, + }), + make_preparation_match({ + _id: 'm2', + setup: { + match_num: 2, + scheduled_time_str: '10:00', + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { from1: 999 }, + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_time_str: '10:20', + }, + }), + make_preparation_match({ + _id: 'm4', + setup: { + match_num: 4, + scheduled_time_str: '10:40', + }, + }), + ], + }; + + const candidates = match_automation.find_location_preparation_candidates(tournament, 'l1'); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm3']); + }); + + _it('limits candidates by match count distance from the first unusable match', () => { + const tournament = { + preparation_call_matches_ahead_of_frontier_enabled: true, + preparation_call_matches_ahead_of_frontier_limit: 1, + courts: [], + matches: [ + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:00', + }, + }), + make_preparation_match({ + _id: 'm2', + setup: { + match_num: 2, + scheduled_time_str: '09:05', + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { from1: 999 }, + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_time_str: '09:10', + }, + }), + make_preparation_match({ + _id: 'm4', + setup: { + match_num: 4, + scheduled_time_str: '09:15', + }, + }), + ], + }; + + const candidates = match_automation.find_location_preparation_candidates(tournament, 'l1'); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm3']); + }); + + _it('ignores incomplete matches when determining the frontier sequence', () => { + const tournament = { + preparation_call_matches_ahead_of_frontier_enabled: true, + preparation_call_matches_ahead_of_frontier_limit: 1, + courts: [], + matches: [ + make_preparation_match({ + _id: 'ko_future', + setup: { + match_num: 100, + state: 'incomplete', + phase_block_key: 'VF', + teams: [ + { players: [] }, + { players: [] }, + ], + }, + }), + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:00', + }, + }), + make_preparation_match({ + _id: 'frontier', + setup: { + match_num: 2, + scheduled_time_str: '09:05', + teams: [ + { players: [{ _id: 'p1' }] }, + { players: [] }, + ], + links: { from1: 999 }, + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_time_str: '09:10', + }, + }), + ], + }; + + const candidates = match_automation.find_location_preparation_candidates(tournament, 'l1'); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm3']); + }); + + _it('selects only as many preparation candidates as are currently missing', () => { + const tournament = { + call_preparation_matches_automatically_enabled: true, + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: null }, + { _id: 'c2', location_id: 'l1', is_active: true, match_id: 'm-oncourt' }, + ], + matches: [ + { + _id: 'm-oncourt', + setup: { + now_on_court: true, + court_id: 'c2', + needs_preparation_successor: true, + }, + }, + { + _id: 'm-prepared', + setup: { + state: 'preparation', + location_id: 'l1', + }, + }, + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:30', + location_id: 'l1', + }, + }), + make_preparation_match({ + _id: 'm2', + setup: { + match_num: 2, + scheduled_time_str: '10:00', + location_id: 'l1', + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_time_str: '10:30', + location_id: 'l1', + }, + }), + ], + }; + + const selection = match_automation.calculate_location_preparation_selection(tournament, 'l1'); + + assert.strictEqual(selection.required_preparation_count, 2); + assert.strictEqual(selection.current_preparation_count, 1); + assert.strictEqual(selection.missing_preparation_count, 1); + assert.deepStrictEqual(selection.candidates.map((match) => match._id), ['m1', 'm2', 'm3']); + assert.deepStrictEqual(selection.selected_matches.map((match) => match._id), ['m1']); + assert.deepStrictEqual(selection.auto_selected_matches.map((match) => match._id), []); + }); + + _it('returns no selected matches when the location already has enough preparation matches', () => { + const tournament = { + courts: [ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: null }, + ], + matches: [ + { + _id: 'm-prepared', + setup: { + state: 'preparation', + location_id: 'l1', + }, + }, + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:30', + location_id: 'l1', + }, + }), + ], + }; + + const selection = match_automation.calculate_location_preparation_selection(tournament, 'l1'); + + assert.strictEqual(selection.required_preparation_count, 1); + assert.strictEqual(selection.current_preparation_count, 1); + assert.strictEqual(selection.missing_preparation_count, 0); + assert.deepStrictEqual(selection.selected_matches, []); + assert.deepStrictEqual(selection.auto_selected_matches, []); + }); + + _it('fetches the preparation selection for one location from db-backed data', async () => { + const app = { + db: { + tournaments: { + findOne_async: async () => ({ + key: 't1', + }), + }, + locations: { + find_async: async () => ([ + { _id: 'l1', name: 'Loc 1' }, + ]), + }, + courts: { + find_async: async () => ([ + { _id: 'c1', location_id: 'l1', is_active: true, match_id: null }, + ]), + }, + matches: { + find_async: async () => ([ + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:30', + location_id: 'l1', + }, + }), + ]), + }, + umpires: { + find_async: async () => ([]), + }, + }, + }; + + const selection = await match_automation.fetch_location_preparation_selection(app, 't1', 'l1'); + + assert.strictEqual(selection.location_id, 'l1'); + assert.strictEqual(selection.location.name, 'Loc 1'); + assert.strictEqual(selection.required_preparation_count, 1); + assert.strictEqual(selection.missing_preparation_count, 1); + assert.deepStrictEqual(selection.selected_matches.map((match) => match._id), ['m1']); + assert.deepStrictEqual(selection.auto_selected_matches.map((match) => match._id), []); + }); + + _it('sorts preparation candidates like the main page', () => { + const tournament = { + courts: [], + matches: [ + make_preparation_match({ + _id: 'm4', + match_order: 2, + setup: { + match_num: 4, + scheduled_date: '2026-04-07', + scheduled_time_str: '10:00', + }, + }), + make_preparation_match({ + _id: 'm2', + match_order: 1, + setup: { + match_num: 2, + scheduled_date: '2026-04-07', + scheduled_time_str: '10:00', + }, + }), + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_date: '2026-04-07', + scheduled_time_str: '09:30', + }, + }), + make_preparation_match({ + _id: 'm3', + setup: { + match_num: 3, + scheduled_date: '2026-04-07', + scheduled_time_str: '00:00', + }, + }), + ], + }; + + const candidates = match_automation.find_location_preparation_candidates(tournament, 'l1'); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm2', 'm4', 'm3']); + }); + + _it('finds global preparation candidates without filtering by location', () => { + const tournament = { + courts: [ + make_court({ _id: 'c1', location_id: 'l1' }), + make_court({ _id: 'c2', location_id: 'l2' }), + ], + matches: [ + make_preparation_match({ + _id: 'm2', + setup: { + match_num: 2, + scheduled_time_str: '10:00', + location_id: 'l2', + }, + }), + make_preparation_match({ + _id: 'm1', + setup: { + match_num: 1, + scheduled_time_str: '09:30', + location_id: 'l1', + }, + }), + ], + }; + + const candidates = match_automation.find_global_preparation_candidates(tournament); + + assert.deepStrictEqual(candidates.map((match) => match._id), ['m1', 'm2']); + }); + + _it('keeps a realistic location fixture stable across active, inactive and prepared matches', () => { + const tournament = make_realistic_location_fixture(); + + const need = match_automation.calculate_location_preparation_need(tournament, 'l-hall'); + const status = match_automation.calculate_location_preparation_status(tournament, 'l-hall'); + const selection = match_automation.calculate_location_preparation_selection(tournament, 'l-hall'); + + assert.strictEqual(need.active_court_count, 2); + assert.strictEqual(need.successor_need_count, 1); + assert.strictEqual(need.free_court_count, 1); + assert.strictEqual(need.required_preparation_count, 2); + + assert.strictEqual(status.current_preparation_count, 1); + assert.strictEqual(status.missing_preparation_count, 1); + + assert.deepStrictEqual(selection.candidates.map((match) => match._id), ['m-eligible-1', 'm-eligible-2']); + assert.deepStrictEqual(selection.selected_matches.map((match) => match._id), ['m-eligible-1']); + assert.deepStrictEqual(selection.auto_selected_matches.map((match) => match._id), []); + }); +}); diff --git a/test/test_match_utils_officials.js b/test/test_match_utils_officials.js new file mode 100644 index 0000000..d8db2d6 --- /dev/null +++ b/test/test_match_utils_officials.js @@ -0,0 +1,645 @@ +'use strict'; + +const assert = require('assert'); + +const { _describe, _it } = require('./tutils.js'); +const match_utils = require('../bts/match_utils.js'); +const admin = require('../bts/admin.js'); +const btp_manager = require('../bts/btp_manager.js'); +const match_automation = require('../bts/match_automation.js'); + +function make_official(overrides = {}) { + const pick = (field, fallback) => Object.prototype.hasOwnProperty.call(overrides, field) ? overrides[field] : fallback; + return { + _id: overrides._id || 'u1', + name: overrides.name || 'Test Official', + is_umpire: overrides.is_umpire === true, + is_service_judge: overrides.is_service_judge === true, + is_planed_as_umpire: overrides.is_planed_as_umpire === true, + is_planed_as_service_judge: overrides.is_planed_as_service_judge === true, + umpire_on_court: pick('umpire_on_court', 'c1'), + service_judge_on_court: pick('service_judge_on_court', null), + umpire_wait: pick('umpire_wait', 111), + service_judge_wait: pick('service_judge_wait', 222), + umpire_pause: pick('umpire_pause', 333), + service_judge_pause: pick('service_judge_pause', 444), + inactive_list: pick('inactive_list', 555), + last_time_on_court_ts: pick('last_time_on_court_ts', 666), + status: overrides.status || 'oncourt', + court_id: pick('court_id', 'c1'), + checked_in: overrides.checked_in === true, + }; +} + +_describe('match utils official state helpers', () => { + _it('moves an official to standby and clears all active list markers', () => { + const official = make_official({ + is_planed_as_umpire: true, + is_planed_as_service_judge: true, + is_umpire: true, + is_service_judge: true, + }); + + const result = match_utils.apply_official_standby_state(official); + + assert.strictEqual(result.status, 'standby'); + assert.strictEqual(result.umpire_on_court, null); + assert.strictEqual(result.service_judge_on_court, null); + assert.strictEqual(result.is_planed_as_umpire, false); + assert.strictEqual(result.is_planed_as_service_judge, false); + assert.strictEqual(result.umpire_wait, null); + assert.strictEqual(result.service_judge_wait, null); + assert.strictEqual(result.umpire_pause, null); + assert.strictEqual(result.service_judge_pause, null); + assert.strictEqual(result.inactive_list, null); + assert.strictEqual(result.last_time_on_court_ts, null); + assert.strictEqual(result.court_id, null); + }); + + _it('releases a service judge from court into the correct wait list', () => { + const end_ts = 123456; + const official = make_official({ + is_umpire: false, + is_service_judge: true, + is_planed_as_service_judge: true, + service_judge_on_court: 'c9', + }); + + const result = match_utils.apply_official_on_court_release(official, 'service_judge', end_ts); + + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.umpire_on_court, null); + assert.strictEqual(result.service_judge_on_court, null); + assert.strictEqual(result.is_planed_as_umpire, false); + assert.strictEqual(result.is_planed_as_service_judge, false); + assert.strictEqual(result.umpire_wait, null); + assert.strictEqual(result.service_judge_wait, end_ts + 100); + assert.strictEqual(result.umpire_pause, null); + assert.strictEqual(result.service_judge_pause, null); + assert.strictEqual(result.inactive_list, null); + assert.strictEqual(result.last_time_on_court_ts, end_ts); + assert.strictEqual(result.court_id, null); + }); + + _it('returns a dual-role umpire back to the service judge wait list in full rotation mode', () => { + const end_ts = 555000; + const official = make_official({ + is_umpire: true, + is_service_judge: true, + is_planed_as_umpire: true, + umpire_on_court: 'c7', + }); + + const result = match_utils.apply_official_on_court_release(official, 'umpire', end_ts); + + assert.strictEqual(result.umpire_wait, null); + assert.strictEqual(result.service_judge_wait, end_ts); + assert.strictEqual(result.inactive_list, null); + }); + + _it('returns any official to the umpire wait list in umpire_only mode', () => { + const end_ts = 777000; + const official = make_official({ + is_umpire: false, + is_service_judge: true, + is_planed_as_service_judge: true, + service_judge_on_court: 'c8', + }); + + const result = match_utils.apply_official_on_court_release(official, 'service_judge', end_ts, { + official_rotation_mode: 'umpire_only' + }); + + assert.strictEqual(result.umpire_wait, end_ts); + assert.strictEqual(result.service_judge_wait, null); + assert.strictEqual(result.inactive_list, null); + }); + + _it('puts a returning official into pause instead of wait when a technical official break is configured', () => { + const end_ts = 888000; + const official = make_official({ + is_umpire: true, + is_service_judge: false, + is_planed_as_umpire: true, + umpire_on_court: 'c3', + }); + + const result = match_utils.apply_official_on_court_release(official, 'umpire', end_ts, { + official_rotation_mode: 'umpire_only', + technical_official_break_after_assignment_ms: 30 * 1000, + }); + + assert.strictEqual(result.status, 'pause'); + assert.strictEqual(result.umpire_pause, end_ts + (30 * 1000)); + assert.strictEqual(result.umpire_wait, null); + }); + + _it('moves an expired technical official break back to the matching wait list', () => { + const official = make_official({ + status: 'pause', + umpire_wait: null, + service_judge_wait: null, + umpire_pause: 123456, + service_judge_pause: null, + }); + + const result = match_utils.apply_official_pause_expiry(official); + + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.umpire_pause, null); + assert.strictEqual(result.umpire_wait, 123456); + assert.strictEqual(result.service_judge_wait, null); + }); + + _it('treats ready technical officials as checked in when check-in is configured per player', () => { + const official = make_official({ + checked_in: false, + umpire_on_court: null, + service_judge_on_court: null, + umpire_wait: 123, + service_judge_wait: null, + umpire_pause: null, + service_judge_pause: null, + inactive_list: null, + }); + + assert.strictEqual( + match_utils.get_effective_technical_official_checked_in(official, { + btp_settings: { check_in_per_match: false } + }), + true + ); + }); + + _it('treats paused or inactive technical officials as not checked in when check-in is configured per player', () => { + const paused_official = make_official({ + checked_in: true, + umpire_wait: null, + umpire_pause: 123456, + inactive_list: null, + }); + const inactive_official = make_official({ + checked_in: true, + umpire_wait: null, + umpire_pause: null, + inactive_list: 987654, + }); + + assert.strictEqual( + match_utils.get_effective_technical_official_checked_in(paused_official, { + btp_settings: { check_in_per_match: false } + }), + false + ); + assert.strictEqual( + match_utils.get_effective_technical_official_checked_in(inactive_official, { + btp_settings: { check_in_per_match: false } + }), + false + ); + }); + + _it('drops stale preparation state when highlight is cleared', () => { + const setup = { + state: 'preparation', + highlight: 0, + preparation_call_timestamp: 123456, + location_id: 'l1', + }; + + const result = match_utils.normalize_preparation_state(setup); + + assert.strictEqual(result.state, 'scheduled'); + assert.strictEqual(result.preparation_call_timestamp, undefined); + assert.strictEqual(result.location_id, 'l1'); + }); + + _it('auto-assigns only an umpire in on_preparation_call + umpire_only mode', (done) => { + const calls = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + const original_assign_next_service_judge_to_match = admin._assign_next_service_judge_to_match; + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.resolve(); + }; + admin._assign_next_service_judge_to_match = (_app, tournament_key, match_id) => { + calls.push(['service_judge', tournament_key, match_id]); + return Promise.resolve(); + }; + + match_utils.auto_assign_technical_officials_for_match( + {}, + { key: 't1', technical_official_auto_assignment_mode: 'on_preparation_call', official_rotation_mode: 'umpire_only' }, + 'm1', + (err) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + admin._assign_next_service_judge_to_match = original_assign_next_service_judge_to_match; + assert.ifError(err); + assert.deepStrictEqual(calls, [['umpire', 't1', 'm1']]); + done(); + } + ); + }); + + _it('auto-assigns umpire and service judge in on_preparation_call + full mode', (done) => { + const calls = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + const original_assign_next_service_judge_to_match = admin._assign_next_service_judge_to_match; + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.resolve(); + }; + admin._assign_next_service_judge_to_match = (_app, tournament_key, match_id) => { + calls.push(['service_judge', tournament_key, match_id]); + return Promise.resolve(); + }; + + match_utils.auto_assign_technical_officials_for_match( + {}, + { key: 't1', technical_official_auto_assignment_mode: 'on_preparation_call', official_rotation_mode: 'umpire_and_service_judge' }, + 'm1', + (err) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + admin._assign_next_service_judge_to_match = original_assign_next_service_judge_to_match; + assert.ifError(err); + assert.deepStrictEqual(calls, [['umpire', 't1', 'm1'], ['service_judge', 't1', 'm1']]); + done(); + } + ); + }); + + _it('still assigns a missing service judge when the umpire is already assigned', (done) => { + const calls = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + const original_assign_next_service_judge_to_match = admin._assign_next_service_judge_to_match; + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.reject(new Error('Match already has assigned umpire')); + }; + admin._assign_next_service_judge_to_match = (_app, tournament_key, match_id) => { + calls.push(['service_judge', tournament_key, match_id]); + return Promise.resolve(); + }; + + match_utils.auto_assign_technical_officials_for_match( + {}, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_and_service_judge' }, + 'm1', + (err, changed) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + admin._assign_next_service_judge_to_match = original_assign_next_service_judge_to_match; + assert.ifError(err); + assert.strictEqual(changed, true); + assert.deepStrictEqual(calls, [['umpire', 't1', 'm1'], ['service_judge', 't1', 'm1']]); + done(); + } + ); + }); + + _it('does nothing outside on_preparation_call mode', (done) => { + const calls = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.resolve(); + }; + + match_utils.auto_assign_technical_officials_for_match( + {}, + { key: 't1', technical_official_auto_assignment_mode: 'manual_only', official_rotation_mode: 'umpire_and_service_judge' }, + 'm1', + (err) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + assert.ifError(err); + assert.deepStrictEqual(calls, []); + done(); + } + ); + }); + + _it('also auto-assigns in when_available mode', (done) => { + const calls = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + const original_assign_next_service_judge_to_match = admin._assign_next_service_judge_to_match; + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.resolve(); + }; + admin._assign_next_service_judge_to_match = (_app, tournament_key, match_id) => { + calls.push(['service_judge', tournament_key, match_id]); + return Promise.resolve(); + }; + + match_utils.auto_assign_technical_officials_for_match( + {}, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_and_service_judge' }, + 'm1', + (err) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + admin._assign_next_service_judge_to_match = original_assign_next_service_judge_to_match; + assert.ifError(err); + assert.deepStrictEqual(calls, [['umpire', 't1', 'm1'], ['service_judge', 't1', 'm1']]); + done(); + } + ); + }); + + _it('fills prepared matches when officials become available in when_available mode', (done) => { + const calls = []; + const pushes = []; + const original_assign_next_umpire_to_match = admin._assign_next_umpire_to_match; + const original_assign_next_service_judge_to_match = admin._assign_next_service_judge_to_match; + const original_update_highlight = btp_manager.update_highlight; + const original_fetch_all_location_preparation_selections = match_automation.fetch_all_location_preparation_selections; + + admin._assign_next_umpire_to_match = (_app, tournament_key, match_id) => { + calls.push(['umpire', tournament_key, match_id]); + return Promise.resolve(); + }; + admin._assign_next_service_judge_to_match = (_app, tournament_key, match_id) => { + calls.push(['service_judge', tournament_key, match_id]); + return Promise.resolve(); + }; + btp_manager.update_highlight = (_app, match) => { + pushes.push(match._id); + }; + match_automation.fetch_all_location_preparation_selections = async () => ([]); + + const matches = [ + { _id: 'm1', tournament_key: 't1', setup: { state: 'preparation', preparation_call_timestamp: 10 } }, + { _id: 'm2', tournament_key: 't1', setup: { state: 'preparation', preparation_call_timestamp: 20 } }, + ]; + const app = { + db: { + matches: { + find(query) { + assert.deepStrictEqual(query, { tournament_key: 't1', 'setup.state': 'preparation' }); + return { + sort(sortQuery) { + assert.deepStrictEqual(sortQuery, { 'setup.preparation_call_timestamp': 1 }); + return { + exec(cb) { + cb(null, matches); + } + }; + } + }; + }, + findOne(query, cb) { + cb(null, matches.find((m) => m._id === query._id) || null); + } + }, + umpires: { + find(_query, cb) { + cb(null, []); + } + } + } + }; + + match_utils.auto_assign_technical_officials_for_preparation_matches( + app, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_only' }, + (err) => { + admin._assign_next_umpire_to_match = original_assign_next_umpire_to_match; + admin._assign_next_service_judge_to_match = original_assign_next_service_judge_to_match; + btp_manager.update_highlight = original_update_highlight; + match_automation.fetch_all_location_preparation_selections = original_fetch_all_location_preparation_selections; + assert.ifError(err); + assert.deepStrictEqual(calls, [['umpire', 't1', 'm1'], ['umpire', 't1', 'm2']]); + assert.deepStrictEqual(pushes, ['m1', 'm2']); + done(); + } + ); + }); + + _it('adds likely next preparation matches after current preparation matches in when_available mode', (done) => { + const original_fetch_all_location_preparation_selections = match_automation.fetch_all_location_preparation_selections; + match_automation.fetch_all_location_preparation_selections = async () => ([ + { + selected_matches: [ + { _id: 'm2', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm3', tournament_key: 't1', setup: { state: 'scheduled' } }, + ], + }, + ]); + + const app = { + db: { + matches: { + find(query) { + assert.deepStrictEqual(query, { tournament_key: 't1', 'setup.state': 'preparation' }); + return { + sort(sortQuery) { + assert.deepStrictEqual(sortQuery, { 'setup.preparation_call_timestamp': 1 }); + return { + exec(cb) { + cb(null, [ + { _id: 'm1', tournament_key: 't1', setup: { state: 'preparation', preparation_call_timestamp: 10 } }, + ]); + } + }; + } + }; + } + }, + umpires: { + find(_query, cb) { + cb(null, []); + } + } + } + }; + + match_utils.fetch_technical_official_assignment_targets( + app, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_only' }, + (err, targets) => { + match_automation.fetch_all_location_preparation_selections = original_fetch_all_location_preparation_selections; + assert.ifError(err); + assert.deepStrictEqual(targets.map((match) => match._id), ['m1', 'm2', 'm3']); + done(); + } + ); + }); + + _it('deduplicates preparation matches against likely next preparation matches', (done) => { + const original_fetch_all_location_preparation_selections = match_automation.fetch_all_location_preparation_selections; + match_automation.fetch_all_location_preparation_selections = async () => ([ + { + selected_matches: [ + { _id: 'm1', tournament_key: 't1', setup: { state: 'preparation' } }, + { _id: 'm2', tournament_key: 't1', setup: { state: 'scheduled' } }, + ], + }, + ]); + + const app = { + db: { + matches: { + find() { + return { + sort() { + return { + exec(cb) { + cb(null, [ + { _id: 'm1', tournament_key: 't1', setup: { state: 'preparation', preparation_call_timestamp: 10 } }, + ]); + } + }; + } + }; + } + }, + umpires: { + find(_query, cb) { + cb(null, []); + } + } + } + }; + + match_utils.fetch_technical_official_assignment_targets( + app, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_only' }, + (err, targets) => { + match_automation.fetch_all_location_preparation_selections = original_fetch_all_location_preparation_selections; + assert.ifError(err); + assert.deepStrictEqual(targets.map((match) => match._id), ['m1', 'm2']); + done(); + } + ); + }); + + _it('fills remaining when_available target slots with global likely matches based on waiting umpires', (done) => { + const original_fetch_all_location_preparation_selections = match_automation.fetch_all_location_preparation_selections; + const original_fetch_global_preparation_candidates = match_automation.fetch_global_preparation_candidates; + match_automation.fetch_all_location_preparation_selections = async (_app, _tkey, options) => { + assert.strictEqual(options?.ignore_technical_officials_available_rule, true); + return ([ + { selected_matches: [] }, + ]); + }; + match_automation.fetch_global_preparation_candidates = async (_app, _tkey, options) => { + assert.strictEqual(options?.ignore_technical_officials_available_rule, true); + return ([ + { _id: 'm10', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm11', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm12', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm13', tournament_key: 't1', setup: { state: 'scheduled' } }, + ]); + }; + + const app = { + db: { + matches: { + find() { + return { + sort() { + return { + exec(cb) { + cb(null, []); + } + }; + } + }; + } + }, + umpires: { + find(query, cb) { + assert.deepStrictEqual(query, { tournament_key: 't1', umpire_wait: { $ne: null } }); + cb(null, [{ _id: 'u1' }, { _id: 'u2' }, { _id: 'u3' }]); + } + } + } + }; + + match_utils.fetch_technical_official_assignment_targets( + app, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_only' }, + (err, targets) => { + match_automation.fetch_all_location_preparation_selections = original_fetch_all_location_preparation_selections; + match_automation.fetch_global_preparation_candidates = original_fetch_global_preparation_candidates; + assert.ifError(err); + assert.deepStrictEqual(targets.map((match) => match._id), ['m10', 'm11', 'm12']); + done(); + } + ); + }); + + _it('ignores already staffed preparation matches when filling global fallback targets', (done) => { + const original_fetch_all_location_preparation_selections = match_automation.fetch_all_location_preparation_selections; + const original_fetch_global_preparation_candidates = match_automation.fetch_global_preparation_candidates; + match_automation.fetch_all_location_preparation_selections = async () => ([ + { selected_matches: [] }, + ]); + match_automation.fetch_global_preparation_candidates = async () => ([ + { _id: 'm10', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm11', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm12', tournament_key: 't1', setup: { state: 'scheduled' } }, + { _id: 'm13', tournament_key: 't1', setup: { state: 'scheduled' } }, + ]); + + const app = { + db: { + matches: { + find() { + return { + sort() { + return { + exec(cb) { + cb(null, [ + { + _id: 'prep-full', + tournament_key: 't1', + setup: { + state: 'preparation', + preparation_call_timestamp: 10, + umpire: { _id: 'u-prep' }, + }, + }, + ]); + } + }; + } + }; + } + }, + umpires: { + find(_query, cb) { + cb(null, [{ _id: 'u1' }, { _id: 'u2' }, { _id: 'u3' }]); + } + } + } + }; + + match_utils.fetch_technical_official_assignment_targets( + app, + { key: 't1', technical_official_auto_assignment_mode: 'when_available', official_rotation_mode: 'umpire_only' }, + (err, targets) => { + match_automation.fetch_all_location_preparation_selections = original_fetch_all_location_preparation_selections; + match_automation.fetch_global_preparation_candidates = original_fetch_global_preparation_candidates; + assert.ifError(err); + assert.deepStrictEqual(targets.map((match) => match._id), ['m10', 'm11', 'm12']); + done(); + } + ); + }); + + _it('prefers free courts that match the current tabletoperator queue order', () => { + const sorted = match_utils.sort_free_courts_for_auto_call( + [ + { _id: 'c1', num: 1 }, + { _id: 'c2', num: 2 }, + { _id: 'c3', num: 3 }, + ], + [ + { _id: 'to1', court: null, played_on_court: 'c2', start_ts: 10 }, + { _id: 'to2', court: null, played_on_court: 'c1', start_ts: 20 }, + ], + { tabletoperator_enabled: true } + ); + + assert.deepStrictEqual(sorted.map((court) => court._id), ['c2', 'c1', 'c3']); + }); +}); diff --git a/ticker/tdata.js b/ticker/tdata.js index 4eadf93..da4f244 100644 --- a/ticker/tdata.js +++ b/ticker/tdata.js @@ -4,12 +4,21 @@ const async = require('async'); const utils = require('../bts/utils'); +function max_game_count(match) { + const scoringFormat = match && match.setup && match.setup.scoring_format; + const numSets = Number(scoringFormat && scoringFormat.numSets); + if (Number.isFinite(numSets) && numSets > 0) { + return numSets; + } + return 3; +} + function prepare_mustache(m) { m.p0str = m.p0.join('\n'); m.p1str = m.p1.join('\n'); - const max_game_count = 3; // TODO look it up from counting - m.gamesplus2 = max_game_count + 2; - const game_ids = utils.range(max_game_count); + const maxGames = max_game_count(m); + m.gamesplus2 = maxGames + 2; + const game_ids = utils.range(maxGames); for (const team_id of [0, 1]) { m['team' + team_id + 'scores'] = game_ids.map((game_idx) => { if (!m.s) return ''; @@ -56,7 +65,7 @@ function recalc(app, cb) { courts_with_matches: courts, }; if (tournament && tournament.last_update) { - td.last_update_str = utils.format_ts(tournament.last_update); + td.last_update_str = utils.format_time_ts(tournament.last_update); } app.ticker_data = td; diff --git a/ticker/tupdate.js b/ticker/tupdate.js index 525c23a..2e7a21a 100644 --- a/ticker/tupdate.js +++ b/ticker/tupdate.js @@ -22,7 +22,22 @@ function handle_tset(app, ws, msg) { if (!_require_msg(ws, msg, ['event'])) { return; } + if (msg.event.tournament_name) { + app.config.tournament_name = msg.event.tournament_name; + } + if (msg.event.tournament_logo) { + app.config.tournament_logo = msg.event.tournament_logo; + app.config.tournament_logo_background_color = msg.event.tournament_logo_background_color; + app.config.tournament_logo_mime = msg.event.tournament_logo_mime; + } else { + app.config.tournament_logo = undefined; + app.config.tournament_logo_background_color = undefined; + app.config.tournament_logo_mime = undefined; + } + if (msg.event.tournament_url) { + app.config.note_html = "Alle Spiele auf Turnier.de"; + } tdata.set(app, msg.event, (err) => { if (err) { serror.silent('Failed tset: ' + err.message + ' ' + err.stack); diff --git a/ticker/tweb.js b/ticker/tweb.js index 55aafae..0bf6b5c 100644 --- a/ticker/tweb.js +++ b/ticker/tweb.js @@ -33,6 +33,9 @@ function main_handler(req, res, next) { const app = req.app; html = html.replace(/{{error_reporting}}/g, JSON.stringify(serror.active(app.config))); html = html.replace(/{{tournament_name_html}}/g, utils.encode_html(app.config.tournament_name)); + html = html.replace(/{{tournament_logo_mime_html}}/g, app.config.tournament_logo_mime || 'image/png'); + html = html.replace(/{{tournament_logo_background_color_html}}/g, app.config.tournament_logo_background_color || '#FFFFFF'); + html = html.replace(/{{tournament_logo_html}}/g, app.config.tournament_logo || 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wIAAgkBA8gL3QoAAAAASUVORK5CYII'); html = html.replace(/{{last_update_str}}/g, utils.encode_html(app.ticker_data ? (app.ticker_data.last_update_str || '') : '')); html = html.replace(/{{note_html}}/g, app.config.note_html); html = html.replace(/{{prefix_html}}/g, app.config.prefix_html || ''); diff --git a/update-bts.sh b/update-bts.sh new file mode 100755 index 0000000..c45f387 --- /dev/null +++ b/update-bts.sh @@ -0,0 +1,6 @@ +#!/bin/bash +cd "$(dirname "$(realpath "$0")")" +git pull +cd ./static/bup/dev +git pull +sudo systemctl restart bts