Files
step3/scripts/ui/simulation_history.gd
T

772 lines
29 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
var main: Control
var page: VBoxContainer
var active_section: PanelContainer
var active_content: VBoxContainer
var history_section: PanelContainer
var history_header: BoxContainer
var table_header: HBoxContainer
var history_list: VBoxContainer
var status_label: Label
var load_more_button: Button
var filter_option: OptionButton
var sort_option: OptionButton
var abandon_dialog: ConfirmationDialog
var pending_simulation_id := ""
var pending_launch := false
var pending_abandon := false
var history_status := "all"
var history_sort := "newest"
var compact_layout := false
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 0)
_build_ui()
_connect_services()
resized.connect(_update_responsive_layout)
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
call_deferred("_update_responsive_layout")
func _build_ui() -> void:
var scroll := ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
page = VBoxContainer.new()
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 18)
scroll.add_child(page)
active_section = PanelContainer.new()
active_section.name = "ActiveSimulationSection"
page.add_child(active_section)
active_content = VBoxContainer.new()
active_content.add_theme_constant_override("separation", 10)
active_section.add_child(active_content)
history_section = PanelContainer.new()
history_section.name = "SimulationHistorySection"
history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
page.add_child(history_section)
var history_content := VBoxContainer.new()
history_content.add_theme_constant_override("separation", 12)
history_section.add_child(history_content)
history_header = BoxContainer.new()
history_header.vertical = false
history_header.add_theme_constant_override("separation", 10)
history_content.add_child(history_header)
var heading := Label.new()
heading.text = "Simulation History"
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Brand.apply_font(heading)
heading.add_theme_font_size_override("font_size", 21)
history_header.add_child(heading)
filter_option = OptionButton.new()
filter_option.name = "HistoryFilter"
filter_option.custom_minimum_size = Vector2(190, 42)
filter_option.add_item("All Simulations")
filter_option.set_item_metadata(0, "all")
filter_option.add_item("Completed")
filter_option.set_item_metadata(1, "completed")
filter_option.add_item("Abandoned")
filter_option.set_item_metadata(2, "abandoned")
filter_option.item_selected.connect(_on_filter_selected)
history_header.add_child(filter_option)
sort_option = OptionButton.new()
sort_option.name = "HistorySort"
sort_option.custom_minimum_size = Vector2(170, 42)
sort_option.add_item("Newest First")
sort_option.set_item_metadata(0, "newest")
sort_option.add_item("Oldest First")
sort_option.set_item_metadata(1, "oldest")
sort_option.item_selected.connect(_on_sort_selected)
history_header.add_child(sort_option)
status_label = Label.new()
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(status_label)
status_label.add_theme_font_size_override("font_size", 15)
history_content.add_child(status_label)
table_header = _build_table_header()
history_content.add_child(table_header)
history_list = VBoxContainer.new()
history_list.add_theme_constant_override("separation", 0)
history_content.add_child(history_list)
load_more_button = Button.new()
load_more_button.text = "Show more"
load_more_button.visible = false
load_more_button.custom_minimum_size = Vector2(150, 42)
load_more_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
load_more_button.pressed.connect(_load_more)
history_content.add_child(load_more_button)
abandon_dialog = ConfirmationDialog.new()
abandon_dialog.title = "Abandon simulation?"
abandon_dialog.dialog_text = "This attempt cannot be resumed and will not receive a predicted score. Completed blocks will remain visible in your history."
abandon_dialog.ok_button_text = "Abandon Simulation"
abandon_dialog.cancel_button_text = "Keep Simulation"
abandon_dialog.confirmed.connect(_confirm_abandon)
add_child(abandon_dialog)
refresh_theme_controls()
func _connect_services() -> void:
if not ExamSimulationService.history_changed.is_connected(_render_history):
ExamSimulationService.history_changed.connect(_render_history)
if not ExamSimulationService.readiness_changed.is_connected(_render_active_section):
ExamSimulationService.readiness_changed.connect(_render_active_section)
if not ExamSimulationService.state_changed.is_connected(_on_simulation_loaded):
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
ExamSimulationService.request_failed.connect(_on_request_failed)
if not AccessService.status_changed.is_connected(_on_access_changed):
AccessService.status_changed.connect(_on_access_changed)
func on_show(_payload: Variant = null) -> void:
pending_simulation_id = ""
pending_launch = false
pending_abandon = false
status_label.text = "Loading simulation history..."
load_more_button.disabled = true
ExamSimulationService.load_readiness()
ExamSimulationService.load_active()
AccessService.refresh()
_load_history_page()
func refresh_theme_controls() -> void:
if page == null:
return
var dark := UIThemeService.is_dark_mode()
active_section.add_theme_stylebox_override("panel", _section_box(dark))
history_section.add_theme_stylebox_override("panel", _section_box(dark))
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
_style_option(filter_option, dark)
_style_option(sort_option, dark)
_style_outline_button(load_more_button, Brand.PURPLE, dark)
_render_active_section()
_render_history()
func _render_active_section() -> void:
if active_content == null:
return
_clear(active_content)
var dark := UIThemeService.is_dark_mode()
var section_title := Label.new()
section_title.text = "Active Simulation" if _has_active_simulation() else "Step 3 Simulation"
Brand.apply_font(section_title)
section_title.add_theme_font_size_override("font_size", 18)
section_title.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("9b73ff"))
active_content.add_child(section_title)
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", _active_card_box(dark))
active_content.add_child(card)
var card_layout := BoxContainer.new()
card_layout.name = "ActiveCardLayout"
card_layout.vertical = compact_layout
card_layout.add_theme_constant_override("separation", 18)
card.add_child(card_layout)
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(94, 94)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.PURPLE, 8))
card_layout.add_child(icon_panel)
var clock := TextureRect.new()
clock.texture = CLOCK_ICON
clock.custom_minimum_size = Vector2(44, 44)
clock.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
clock.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
clock.modulate = Brand.PURPLE if not dark else Color("9668ff")
icon_panel.add_child(clock)
var details := VBoxContainer.new()
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_theme_constant_override("separation", 5)
card_layout.add_child(details)
var active := _has_active_simulation()
var heading := Label.new()
heading.text = "Step 3 Day 1 Simulation"
Brand.apply_font(heading)
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Brand.text_color(dark))
details.add_child(heading)
if active:
_build_active_details(details, dark)
else:
_build_start_details(details, dark)
var actions := VBoxContainer.new()
actions.name = "ActiveActions"
actions.custom_minimum_size = Vector2(265, 0) if not compact_layout else Vector2.ZERO
actions.size_flags_vertical = Control.SIZE_SHRINK_CENTER
actions.add_theme_constant_override("separation", 10)
card_layout.add_child(actions)
var primary := Button.new()
primary.name = "PrimarySimulationAction"
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
primary.custom_minimum_size = Vector2(0, 48)
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
_style_gradient_button(primary)
primary.pressed.connect(_launch_or_resume)
actions.add_child(primary)
if active:
var abandon := Button.new()
abandon.name = "AbandonSimulation"
abandon.text = "Abandon Simulation"
abandon.custom_minimum_size = Vector2(0, 46)
_style_outline_button(abandon, Color("ff5f76"), dark)
abandon.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220)))
actions.add_child(abandon)
var notice := PanelContainer.new()
notice.add_theme_stylebox_override("panel", _notice_box(dark))
active_content.add_child(notice)
var notice_label := Label.new()
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
notice_label.text = "Abandoning will end your active simulation; completed blocks remain in history, but no score will be generated." if active else _readiness_message()
Brand.apply_font(notice_label)
notice_label.add_theme_font_size_override("font_size", 14)
notice_label.add_theme_color_override("font_color", Brand.muted_color(dark))
notice.add_child(notice_label)
func _build_active_details(details: VBoxContainer, dark: bool) -> void:
var simulation := ExamSimulationService.simulation
var summary := _active_history_summary()
var started := str(summary.get("createdUtc", ""))
var started_label := Label.new()
started_label.text = "Started: %s" % _format_local_datetime(started)
_style_muted_label(started_label, dark, 14)
details.add_child(started_label)
var progress_title := Label.new()
progress_title.text = "Progress"
_style_muted_label(progress_title, dark, 13)
details.add_child(progress_title)
var completed := completed_blocks(simulation if not simulation.is_empty() else summary)
var total := int((simulation if not simulation.is_empty() else summary).get("totalBlocks", 12))
var progress_row := HBoxContainer.new()
progress_row.add_theme_constant_override("separation", 12)
details.add_child(progress_row)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.max_value = max(1, total)
bar.value = completed
bar.custom_minimum_size = Vector2(120, 8)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
bar.add_theme_stylebox_override("background", _progress_box(Color("28344f") if dark else Color("e2e8f0")))
bar.add_theme_stylebox_override("fill", _progress_box(Brand.PURPLE))
progress_row.add_child(bar)
var percent := Label.new()
percent.text = "%d%%" % progress_percent(completed, total)
_style_muted_label(percent, dark, 13)
progress_row.add_child(percent)
var progress_copy := Label.new()
progress_copy.text = "%d of %d blocks complete • %s" % [completed, total, remaining_time_text(simulation if not simulation.is_empty() else summary)]
_style_muted_label(progress_copy, dark, 14)
details.add_child(progress_copy)
func _build_start_details(details: VBoxContainer, dark: bool) -> void:
var copy := Label.new()
copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
copy.text = "A full-length, 12-block simulation with a provisional predicted Step 3 score. Progress is saved between blocks."
_style_muted_label(copy, dark, 15)
details.add_child(copy)
func _render_history() -> void:
if history_list == null or not visible:
return
_clear(history_list)
var items: Array = ExamSimulationService.history
if items.is_empty():
status_label.text = "No simulations match this filter."
else:
status_label.text = ""
for raw_summary in items:
var summary: Dictionary = raw_summary
if str(summary.get("state", "")) in ["block_active", "between_blocks"]:
continue
history_list.add_child(_build_history_row(summary))
load_more_button.visible = not ExamSimulationService.history_next_cursor.is_empty()
load_more_button.disabled = false
table_header.visible = not compact_layout and not items.is_empty()
func _build_table_header() -> HBoxContainer:
var dark := UIThemeService.is_dark_mode()
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
for definition in [["Simulation", 2.2], ["Date", 1.15], ["Status", 1.0], ["Blocks", 0.8], ["Score", 1.15], ["Pass chance", 1.0], ["Actions", 1.0]]:
var label := Label.new()
label.text = definition[0]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.size_flags_stretch_ratio = definition[1]
Brand.apply_font(label)
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Brand.muted_color(dark))
row.add_child(label)
return row
func _build_history_row(summary: Dictionary) -> Control:
var dark := UIThemeService.is_dark_mode()
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", _row_box(dark))
var state := str(summary.get("state", ""))
if compact_layout:
var card := VBoxContainer.new()
card.add_theme_constant_override("separation", 8)
panel.add_child(card)
var top := HBoxContainer.new()
card.add_child(top)
top.add_child(_simulation_cell(summary, dark))
var badge := _status_badge(state, dark)
top.add_child(badge)
var metrics := Label.new()
metrics.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
metrics.text = "%s • %d/%d blocks\n%s • %s" % [_format_local_datetime(str(summary.get("createdUtc", ""))), completed_blocks(summary), int(summary.get("totalBlocks", 12)), _score_text(summary), _pass_chance_text(summary)]
_style_muted_label(metrics, dark, 14)
card.add_child(metrics)
if state == "completed":
card.add_child(_review_button(summary, dark))
return panel
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 66)
row.add_theme_constant_override("separation", 10)
panel.add_child(row)
_add_table_cell(row, _simulation_cell(summary, dark), 2.2)
_add_table_cell(row, _plain_cell(_format_local_datetime(str(summary.get("createdUtc", ""))), dark), 1.15)
_add_table_cell(row, _status_badge(state, dark), 1.0)
_add_table_cell(row, _plain_cell("%d / %d" % [completed_blocks(summary), int(summary.get("totalBlocks", 12))], dark), 0.8)
_add_table_cell(row, _plain_cell(_score_text(summary), dark), 1.15)
_add_table_cell(row, _plain_cell(_pass_chance_text(summary), dark), 1.0)
var action: Control
if state == "completed":
action = _review_button(summary, dark)
else:
action = _plain_cell("-", dark)
_add_table_cell(row, action, 1.0)
return panel
func _simulation_cell(summary: Dictionary, dark: bool) -> Control:
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_label := Label.new()
name_label.text = "Step 3 Day 1 Simulation%s" % (" (Sample)" if bool(summary.get("isSample", false)) else "")
Brand.apply_font(name_label)
name_label.add_theme_font_size_override("font_size", 15)
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
column.add_child(name_label)
var blocks := Label.new()
blocks.text = "%d blocks" % int(summary.get("totalBlocks", 12))
_style_muted_label(blocks, dark, 12)
column.add_child(blocks)
return column
func _plain_cell(text_value: String, dark: bool) -> Label:
var label := Label.new()
label.text = text_value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(label)
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Brand.text_color(dark))
return label
func _status_badge(state: String, dark: bool) -> Label:
var label := Label.new()
var accent := Brand.SUCCESS if state == "completed" else Brand.SLATE
label.text = "Completed" if state == "completed" else "Abandoned"
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
Brand.apply_font(label)
label.add_theme_font_size_override("font_size", 12)
label.add_theme_color_override("font_color", accent if dark else accent.darkened(0.2))
label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
return label
func _review_button(summary: Dictionary, dark: bool) -> Button:
var button := Button.new()
button.text = "Review"
button.icon = REVIEW_ICON
button.expand_icon = true
button.add_theme_constant_override("icon_max_width", 17)
button.custom_minimum_size = Vector2(105, 38)
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
_style_outline_button(button, Brand.PURPLE, dark)
button.pressed.connect(_open_simulation.bind(str(summary.get("id", ""))))
return button
func _add_table_cell(row: HBoxContainer, control: Control, ratio: float) -> void:
control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
control.size_flags_vertical = Control.SIZE_SHRINK_CENTER
control.size_flags_stretch_ratio = ratio
row.add_child(control)
func _open_simulation(simulation_id: String) -> void:
pending_launch = false
pending_simulation_id = simulation_id
status_label.text = "Opening simulation results..."
ExamSimulationService.load_simulation(simulation_id)
func _on_simulation_loaded() -> void:
if not visible:
return
if pending_abandon:
if ExamSimulationService.simulation.is_empty():
pending_abandon = false
ExamSimulationService.load_readiness()
ExamSimulationService.load_active()
_load_history_page()
_render_active_section()
return
if pending_launch:
pending_launch = false
AccessService.refresh()
_route_loaded_simulation()
return
if not pending_simulation_id.is_empty():
pending_simulation_id = ""
_route_loaded_simulation()
return
_render_active_section()
func _route_loaded_simulation() -> void:
if ExamSimulationService.simulation.is_empty():
_on_request_failed("The simulation is no longer available.")
return
var state := str(ExamSimulationService.simulation.get("state", ""))
if state == "completed":
main.show_screen("simulation_results", {"simulation": ExamSimulationService.simulation, "return_screen": "simulation_history"})
elif state == "block_active":
BlockSessionService.start_simulation_block(
ExamSimulationService.get_current_questions(),
int(ExamSimulationService.simulation.get("currentBlock", 0)),
int(ExamSimulationService.simulation.get("totalBlocks", 12)),
ExamSimulationService.get_block_deadline_unix())
BlockSessionService.restore_simulation_responses(ExamSimulationService.simulation.get("responses", []))
main.show_screen("player")
elif state == "between_blocks":
main.show_screen("simulation_break")
else:
_on_request_failed("This simulation can no longer be resumed.")
func _launch_or_resume() -> void:
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
main.show_screen("access_store")
return
pending_launch = true
pending_simulation_id = ""
_render_active_section()
if _has_active_simulation():
if not ExamSimulationService.simulation.is_empty():
pending_launch = false
_route_loaded_simulation()
else:
ExamSimulationService.load_active()
else:
ExamSimulationService.create_simulation()
func _confirm_abandon() -> void:
if not _has_active_simulation() or ExamSimulationService.simulation.is_empty():
return
pending_abandon = true
ExamSimulationService.abandon()
_render_active_section()
func _on_filter_selected(index: int) -> void:
history_status = str(filter_option.get_item_metadata(index))
_load_history_page()
func _on_sort_selected(index: int) -> void:
history_sort = str(sort_option.get_item_metadata(index))
_load_history_page()
func _load_history_page() -> void:
status_label.text = "Loading simulation history..."
load_more_button.disabled = true
ExamSimulationService.load_history("", history_status, history_sort)
func _load_more() -> void:
if ExamSimulationService.history_next_cursor.is_empty():
return
load_more_button.disabled = true
ExamSimulationService.load_history(ExamSimulationService.history_next_cursor, history_status, history_sort)
func _on_request_failed(message: String) -> void:
if not visible:
return
pending_launch = false
pending_simulation_id = ""
pending_abandon = false
status_label.text = message
load_more_button.disabled = false
_render_active_section()
func _on_access_changed(_status: Dictionary) -> void:
if visible:
_render_active_section()
func _update_responsive_layout() -> void:
var use_compact := size.x < 940
if compact_layout == use_compact:
return
compact_layout = use_compact
history_header.vertical = compact_layout
filter_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
sort_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
_render_active_section()
_render_history()
func _has_active_simulation() -> bool:
return has_active_simulation(ExamSimulationService.readiness, ExamSimulationService.simulation)
func _active_history_summary() -> Dictionary:
for raw_summary in ExamSimulationService.history:
var summary: Dictionary = raw_summary
if str(summary.get("state", "")) in ["block_active", "between_blocks"]:
return summary
return ExamSimulationService.simulation
func _readiness_message() -> String:
if bool(ExamSimulationService.readiness.get("ready", false)):
var tokens := int(AccessService.status.get("examTokens", 0))
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
func _score_text(summary: Dictionary) -> String:
if summary.get("predictedScore", null) == null:
return "—"
return "%d (%d–%d)" % [int(summary.get("predictedScore", 0)), int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0))]
func _pass_chance_text(summary: Dictionary) -> String:
if summary.get("passProbability", null) == null:
return "—"
return "%d%%" % int(round(float(summary.get("passProbability", 0.0)) * 100.0))
func _format_local_datetime(value: String) -> String:
if value.is_empty():
return "—"
var unix := Time.get_unix_time_from_datetime_string(value)
var timezone: Dictionary = Time.get_time_zone_from_system()
var local := Time.get_datetime_dict_from_unix_time(int(unix) + int(timezone.get("bias", 0)) * 60)
return format_datetime_parts(local)
static func format_datetime_parts(parts: Dictionary) -> String:
const MONTHS := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
var month_index: int = clamp(int(parts.get("month", 1)) - 1, 0, 11)
var hour_24 := int(parts.get("hour", 0))
var suffix := "AM" if hour_24 < 12 else "PM"
var hour_12 := hour_24 % 12
if hour_12 == 0:
hour_12 = 12
return "%s %d, %d\n%d:%02d %s" % [MONTHS[month_index], int(parts.get("day", 1)), int(parts.get("year", 1970)), hour_12, int(parts.get("minute", 0)), suffix]
static func completed_blocks(summary: Dictionary) -> int:
var state := str(summary.get("state", ""))
var current := int(summary.get("currentBlock", -1))
var total: int = max(0, int(summary.get("totalBlocks", 12)))
if state == "completed":
return total
if state == "between_blocks":
return clamp(current + 1, 0, total)
if state == "block_active":
return clamp(current, 0, total)
return clamp(current + 1, 0, total)
static func progress_percent(completed: int, total: int) -> int:
if total <= 0:
return 0
return int(round(float(completed) * 100.0 / float(total)))
static func remaining_time_text(summary: Dictionary, now_unix: float = -1.0) -> String:
var total: int = max(0, int(summary.get("totalBlocks", 12)))
var current := int(summary.get("currentBlock", -1))
var state := str(summary.get("state", ""))
var seconds := 0
if state == "block_active":
var future_blocks: int = max(0, total - current - 1)
seconds = future_blocks * 1800
var deadline := str(summary.get("blockDeadlineUtc", ""))
if not deadline.is_empty():
var current_unix := Time.get_unix_time_from_system() if now_unix < 0.0 else now_unix
seconds += max(0, int(Time.get_unix_time_from_datetime_string(deadline) - current_unix))
else:
seconds += 1800
else:
seconds = max(0, total - completed_blocks(summary)) * 1800
var hours := int(floor(float(seconds) / 3600.0))
var minutes := int(floor(float(seconds % 3600) / 60.0))
if hours > 0 and minutes > 0:
return "~%dh %dm remaining" % [hours, minutes]
if hours > 0:
return "~%dh remaining" % hours
return "~%dm remaining" % minutes
static func has_active_simulation(readiness: Dictionary, simulation: Dictionary) -> bool:
if bool(readiness.get("hasActiveSimulation", false)):
return true
if simulation.is_empty():
return false
return str(simulation.get("state", "")) in ["between_blocks", "block_active"]
static func _action_for_state(state: String) -> String:
if state == "completed":
return "results"
if state == "block_active" or state == "between_blocks":
return "resume"
return "none"
func _style_muted_label(label: Label, dark: bool, font_size: int) -> void:
Brand.apply_font(label)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", Brand.muted_color(dark))
func _style_option(option: OptionButton, dark: bool) -> void:
Brand.apply_font(option)
option.add_theme_font_size_override("font_size", 14)
option.add_theme_color_override("font_color", Brand.text_color(dark))
for state in ["normal", "hover", "pressed"]:
option.add_theme_stylebox_override(state, Brand.secondary_button_box(dark, state != "normal"))
func _style_gradient_button(button: Button) -> void:
Brand.apply_font(button)
button.add_theme_font_size_override("font_size", 15)
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
func _style_outline_button(button: Button, accent: Color, dark: bool) -> void:
Brand.apply_font(button)
button.add_theme_font_size_override("font_size", 14)
var normal := Style.button_box(Color(0, 0, 0, 0), accent)
var hover := Style.button_box(Color(accent, 0.12), accent)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", hover)
button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08))
button.add_theme_color_override("font_hover_color", accent)
button.add_theme_color_override("icon_normal_color", accent)
func _section_box(dark: bool) -> StyleBoxFlat:
var box := Brand.content_card_box(dark, false)
box.content_margin_left = 16
box.content_margin_right = 16
box.content_margin_top = 14
box.content_margin_bottom = 14
return box
func _active_card_box(dark: bool) -> StyleBoxFlat:
var box := Brand.content_card_box(dark, true, Color("7455ff"))
box.bg_color = Color("101a38") if dark else Color("f8f7ff")
box.border_color = Color("3d326f") if dark else Color("d9d1ff")
box.content_margin_left = 16
box.content_margin_right = 16
box.content_margin_top = 16
box.content_margin_bottom = 16
return box
func _notice_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("101a32") if dark else Color("f5f7ff")
box.border_color = Color("29365d") if dark else Color("dbe3f4")
box.set_border_width_all(1)
box.set_corner_radius_all(7)
box.content_margin_left = 12
box.content_margin_right = 12
box.content_margin_top = 9
box.content_margin_bottom = 9
return box
func _row_box(dark: bool) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("0c162b") if dark else Color("ffffff")
box.border_color = Color("273450") if dark else Color("e3e8f1")
box.border_width_top = 1
box.content_margin_left = 12
box.content_margin_right = 12
box.content_margin_top = 10
box.content_margin_bottom = 10
return box
func _progress_box(color: Color) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = color
box.set_corner_radius_all(4)
return box
func _clear(container: Node) -> void:
for child in container.get_children():
container.remove_child(child)
child.queue_free()