Weekly Load Periodisation Tracking - Part 5 of Athlete Monitoring Series
In this fifth instalment, we focus on the critical aspect of weekly periodisation - the structured management of training loads across microcycles.
Introduction
The weekly microcycle forms the foundation of modern periodisation. Unlike daily session monitoring or seasonal planning, weekly load periodisation sits at the sweet spot where tactical decisions meet physiological science. It’s here that we balance the competing demands of adaptation stimulus, recovery requirements, and competition preparation.
Effective weekly periodisation isn’t just about distributing training load evenly across seven days - it’s about understanding how different load patterns affect adaptation, fatigue accumulation, and performance readiness. The case study from Lommel SK’s gameload-based approach demonstrates how strategic weekly load management can prepare players for both immediate competition demands and long-term development goals.
Modern football demands have evolved to require sophisticated periodisation strategies. Players must be prepared for congested fixture periods, varied opponent tactics, and the physical demands of high-speed modern football. The acute:chronic workload ratio (ACWR) has emerged as a crucial tool for managing these demands, providing insights into when players are optimally loaded, under-loaded, or at risk of overreaching.
Building on Gabbett and Oetter’s recent work on tissue-specific loading responses, we understand that different tissues recover at different rates, requiring careful consideration of load timing and distribution within the microcycle. This knowledge informs our approach to weekly periodisation, ensuring that training stimuli are appropriately sequenced to maximise adaptation while respecting recovery requirements.
This comprehensive Weekly Load Periodisation Tracker will enable practitioners to:
Aggregate and analyse weekly load metrics with comprehensive statistical profiling
Monitor acute:chronic workload ratios with risk zone identification and trend analysis
Track seasonal periodisation phases and assess their effectiveness
Analyse team-wide load patterns and identify optimisation opportunities
Provide load planning tools with target setting and recommendation systems
Enable comprehensive weekly progression tracking across multiple metrics and timeframes
Let’s explore each objective in detail, understanding both the theoretical foundation and practical implementation strategies.
Objective 1: Aggregate and Analyse Weekly Load Metrics
Why This Matters
Weekly aggregation transforms daily training noise into meaningful patterns. While individual sessions provide important details, the weekly view reveals periodisation effectiveness, load progression trends, and adaptation patterns that aren’t visible at the session level.
Key benefits of weekly aggregation include:
Pattern recognition: Identifying consistent training rhythms and their effects
Load progression tracking: Understanding how training builds over time
Recovery assessment: Evaluating whether load distribution supports adaptation
Consistency measurement: Determining training regularity and its impact on outcomes
Research supports weekly load monitoring as essential for balancing sensitivity to change with stability against daily fluctuations.
The Implementation
# Comprehensive weekly metrics calculation
calculate_weekly_metrics <- function(data) {
data %>%
group_by(`Player ID`, week_start, week_year, season_phase) %>%
summarise(
# Session counts and composition
total_sessions = n(),
training_sessions = sum(`Session Day Type` != "Match Day"),
match_sessions = sum(`Session Day Type` == "Match Day"),
recovery_sessions = sum(`Session Day Type` == "Recovery Training"),
physical_sessions = sum(`Session Day Type` == "Physical Training"),
tactical_sessions = sum(`Session Day Type` == "Tactical Training"),
# Volume metrics (absolute weekly totals)
total_duration = sum(`Duration (min)`, na.rm = TRUE),
total_distance = sum(`Distance (m)`, na.rm = TRUE),
total_hsr = sum(`High speed running (m) (19,8 – 25,2 km/h)`, na.rm = TRUE),
total_sprint = sum(`Sprinting (m) (>25,2km/h)`, na.rm = TRUE),
total_accelerations = sum(`Accelerations (>3m/s2)`, na.rm = TRUE),
total_decelerations = sum(`Decelerations (>3m/s2)`, na.rm = TRUE),
# Intensity metrics (weekly averages)
avg_distance_per_min = mean(`Distance per min (m)`, na.rm = TRUE),
max_speed_week = max(`Max speed (km/h)`, na.rm = TRUE),
total_session_load = sum(session_load, na.rm = TRUE),
total_training_stress = sum(training_stress, na.rm = TRUE),
# Derived weekly metrics
hsr_percentage = (total_hsr / total_distance) * 100,
sprint_percentage = (total_sprint / total_distance) * 100,
avg_session_duration = mean(`Duration (min)`, na.rm = TRUE),
# Weekly load distribution patterns
sessions_per_day = total_sessions / 7,
training_density = training_sessions / 7,
.groups = 'drop'
) %>%
# Calculate rolling metrics and ratios
group_by(`Player ID`) %>%
arrange(week_start) %>%
mutate(
# 4-week chronic load (rolling average)
chronic_distance = rollmean(total_distance, k = 4, fill = NA, align = "right"),
chronic_hsr = rollmean(total_hsr, k = 4, fill = NA, align = "right"),
chronic_session_load = rollmean(total_session_load, k = 4, fill = NA, align = "right"),
chronic_training_stress = rollmean(total_training_stress, k = 4, fill = NA, align = "right"),
# Acute:Chronic ratios (current week / 4-week average)
ac_distance_ratio = total_distance / chronic_distance,
ac_hsr_ratio = total_hsr / chronic_hsr,
ac_session_load_ratio = total_session_load / chronic_session_load,
ac_training_stress_ratio = total_training_stress / chronic_training_stress,
# Week-to-week percentage changes
distance_change = (total_distance - lag(total_distance)) / lag(total_distance) * 100,
hsr_change = (total_hsr - lag(total_hsr)) / lag(total_hsr) * 100,
session_load_change = (total_session_load - lag(total_session_load)) / lag(total_session_load) * 100,
# Load monotony and strain (Foster, 1998 adapted for weekly data)
load_monotony = total_session_load / (sd(c(total_session_load,
lag(total_session_load, 1),
lag(total_session_load, 2)), na.rm = TRUE) + 1),
training_strain = total_training_stress * load_monotony
) %>%
ungroup()
}
# Enhanced data preprocessing for weekly analysis
training_data <- training_data %>%
mutate(
# Weekly grouping variables
week_start = floor_date(Date, "week", week_start = 1), # Monday start
week_number = isoweek(Date),
year = year(Date),
week_year = paste(year, sprintf("%02d", week_number), sep = "-W"),
# Composite load metrics
session_load = `Duration (min)` * `Distance per min (m)`,
high_intensity_distance = `High speed running (m) (19,8 – 25,2 km/h)` + `Sprinting (m) (>25,2km/h)`,
# Training stress score (weighted by session intensity)
training_stress = case_when(
load_category == "Very High" ~ session_load * 1.5,
load_category == "High" ~ session_load * 1.2,
load_category == "Moderate" ~ session_load * 1.0,
load_category == "Low" ~ session_load * 0.7,
load_category == "Very Low" ~ session_load * 0.5,
TRUE ~ session_load
),
# Season phase classification
season_phase = case_when(
month(Date) %in% c(6, 7, 8) ~ "Pre-Season",
month(Date) %in% c(9, 10, 11, 12) ~ "Early Season",
month(Date) %in% c(1, 2, 3) ~ "Mid Season",
month(Date) %in% c(4, 5) ~ "End Season",
TRUE ~ "Transition"
)
)This comprehensive aggregation creates a rich weekly dataset that captures both absolute load values and relative changes over time. The training stress score provides a weighted measure that accounts for session intensity, while the monotony and strain calculations help identify potentially problematic load patterns.
Weekly Load Distribution Visualisation
# Interactive weekly load progression with multiple metrics
output$weekly_load_distribution <- renderPlotly({
plot_data <- filtered_weekly_data() %>%
arrange(week_start)
p <- ggplot(plot_data, aes(x = week_start, y = get(input$primary_metric_weekly),
color = `Player ID`)) +
geom_line(size = 1.2, alpha = 0.8) +
geom_point(size = 2.5, alpha = 0.9) +
scale_color_viridis_d(option = "plasma") +
labs(
title = paste("Weekly", str_to_title(gsub("_", " ", input$primary_metric_weekly)), "Progression"),
subtitle = paste("Tracking", length(input$selected_players_weekly), "players over",
length(unique(plot_data$week_start)), "weeks"),
x = "Week Starting",
y = str_to_title(gsub("_", " ", input$primary_metric_weekly)),
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(size = 14, face = "bold"),
axis.text = element_text(size = 10)
)
# Add team average line if requested
if(input$show_team_avg) {
team_avg_data <- plot_data %>%
group_by(week_start) %>%
summarise(team_avg = mean(get(input$primary_metric_weekly), na.rm = TRUE), .groups = 'drop')
p <- p + geom_line(data = team_avg_data, aes(x = week_start, y = team_avg, color = NULL),
color = "black", size = 2, linetype = "dashed", alpha = 0.8,
inherit.aes = FALSE) +
annotate("text", x = max(team_avg_data$week_start), y = max(team_avg_data$team_avg),
label = "Team Average", hjust = 1, vjust = -0.5, size = 3)
}
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
})Objective 2: Monitor Acute:Chronic Workload Ratios with Risk Assessment
Why This Matters
The acute:chronic workload ratio (ACWR) represents one of the most important developments in load monitoring over the past decade. Research consistently shows that players with ACWR values outside the optimal range (typically 0.8-1.3) face increased injury risk.
Key ACWR insights include (Read up on this - it can be argued!?):
Sweet spot identification: Values between 0.8-1.3 are associated with lower injury risk
High-risk zones: Ratios >1.5 or <0.5 indicate elevated injury probability
Trend importance: Consistent patterns matter more than isolated spikes
Individual variation: Optimal ranges may vary between players based on history and capacity
The Implementation
# AC Ratio tracking with comprehensive risk assessment
output$ac_ratio_tracking <- renderPlotly({
ac_data <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio)) %>%
arrange(week_start)
p <- ggplot(ac_data, aes(x = week_start, y = ac_session_load_ratio, color = `Player ID`)) +
geom_line(size = 1.2, alpha = 0.8) +
geom_point(size = 2.5, alpha = 0.9) +
# Risk zone indicators
geom_hline(yintercept = 0.8, linetype = "dashed", color = "#2ecc71", alpha = 0.8, size = 1) +
geom_hline(yintercept = 1.3, linetype = "dashed", color = "#2ecc71", alpha = 0.8, size = 1) +
geom_hline(yintercept = 1.5, linetype = "dashed", color = "#e74c3c", alpha = 0.8, size = 1) +
geom_hline(yintercept = 0.5, linetype = "dashed", color = "#e74c3c", alpha = 0.8, size = 1) +
# Colored risk zones
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0.8, ymax = 1.3,
fill = "#2ecc71", alpha = 0.1) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 1.5, ymax = Inf,
fill = "#e74c3c", alpha = 0.15) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = 0.5,
fill = "#e74c3c", alpha = 0.15) +
# Zone labels
annotate("text", x = max(ac_data$week_start), y = 1.05,
label = "OPTIMAL ZONE", hjust = 1, size = 3, fontface = "bold", color = "#27ae60") +
annotate("text", x = max(ac_data$week_start), y = 1.7,
label = "HIGH RISK", hjust = 1, size = 3, fontface = "bold", color = "#c0392b") +
annotate("text", x = max(ac_data$week_start), y = 0.3,
label = "HIGH RISK", hjust = 1, size = 3, fontface = "bold", color = "#c0392b") +
scale_color_viridis_d() +
labs(
title = "Acute:Chronic Workload Ratio Monitoring",
subtitle = paste("Optimal range: 0.8-1.3 | High risk: >1.5 or <0.5 |",
input$ac_ratio_window, "week chronic window"),
x = "Week Starting",
y = "AC Workload Ratio",
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(size = 14, face = "bold"),
plot.subtitle = element_text(size = 11, color = "gray60")
)
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
})
# ACWR Risk Distribution Analysis
output$acwr_risk_distribution <- renderPlotly({
risk_data <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio)) %>%
mutate(
risk_category = case_when(
ac_session_load_ratio < 0.5 ~ "Very High Risk",
ac_session_load_ratio < 0.8 ~ "Moderate Risk",
ac_session_load_ratio <= 1.3 ~ "Optimal",
ac_session_load_ratio <= 1.5 ~ "Moderate Risk",
TRUE ~ "Very High Risk"
)
)
p <- ggplot(risk_data, aes(x = ac_session_load_ratio)) +
geom_histogram(aes(fill = risk_category), bins = 30, alpha = 0.8, color = "white") +
geom_vline(xintercept = c(0.8, 1.3), linetype = "dashed", color = "black", alpha = 0.6) +
scale_fill_manual(
values = c("Optimal" = "#2ecc71", "Moderate Risk" = "#f39c12",
"Very High Risk" = "#e74c3c"),
name = "Risk Category"
) +
labs(
title = "ACWR Distribution Across All Weekly Observations",
subtitle = "Distribution shows the frequency of different risk categories",
x = "Acute:Chronic Workload Ratio",
y = "Frequency"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p)
})ACWR Value Boxes with Dynamic Colouring
# Enhanced value boxes with intelligent color coding
output$current_week_acwr_box <- renderValueBox({
current_acwr_data <- filtered_weekly_data() %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
summarise(
avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
high_risk_players = sum(ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5, na.rm = TRUE),
total_players = n()
)
avg_acwr <- round(current_acwr_data$avg_acwr, 2)
risk_players <- current_acwr_data$high_risk_players
# Dynamic coloring based on ACWR value and risk players
color <- if(avg_acwr < 0.8 || avg_acwr > 1.3) "red"
else if(avg_acwr < 0.9 || avg_acwr > 1.2) "orange"
else "green"
if(risk_players > 2) color <- "red"
icon_name <- if(avg_acwr >= 0.8 && avg_acwr <= 1.3 && risk_players <= 1) "check-circle"
else if(avg_acwr > 1.3) "exclamation-triangle"
else "arrow-down"
subtitle_text <- paste("Team Average ACWR (", risk_players, " high-risk players)")
valueBox(
value = avg_acwr,
subtitle = subtitle_text,
icon = icon(icon_name),
color = color
)
})Objective 3: Track Seasonal Periodisation Phases
Why This Matters
Different phases of the season require different periodisation strategies. Pre-season focuses on building capacity, early season on maintaining fitness while developing tactical patterns, mid-season on performance maintenance, and end-season on peaking for playoffs or tournaments.
Understanding how load patterns change across these phases helps practitioners:
Validate periodisation strategies: Are we achieving the intended load patterns?
Identify phase-specific adaptations: Which training approaches work best when?
Plan future seasons: What patterns led to successful outcomes?
Optimise transition periods: How should load change between phases?
The Implementation
# Seasonal phase analysis with comprehensive metrics
calculate_phase_characteristics <- function(data) {
data %>%
group_by(season_phase) %>%
summarise(
# Basic characteristics
weeks_in_phase = n_distinct(week_start),
total_observations = n(),
avg_players_per_week = n() / n_distinct(week_start),
# Load characteristics
avg_weekly_distance = mean(total_distance, na.rm = TRUE),
avg_weekly_hsr = mean(total_hsr, na.rm = TRUE),
avg_weekly_sessions = mean(total_sessions, na.rm = TRUE),
avg_training_density = mean(training_density, na.rm = TRUE),
# Intensity characteristics
avg_session_intensity = mean(avg_distance_per_min, na.rm = TRUE),
avg_hsr_percentage = mean(hsr_percentage, na.rm = TRUE),
avg_max_speed = mean(max_speed_week, na.rm = TRUE),
# Load variability within phase
distance_cv = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
hsr_cv = sd(total_hsr, na.rm = TRUE) / mean(total_hsr, na.rm = TRUE) * 100,
# ACWR characteristics
avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
acwr_above_13 = sum(ac_session_load_ratio > 1.3, na.rm = TRUE),
acwr_below_08 = sum(ac_session_load_ratio < 0.8, na.rm = TRUE),
optimal_acwr_percentage = sum(ac_session_load_ratio >= 0.8 &
ac_session_load_ratio <= 1.3, na.rm = TRUE) /
sum(!is.na(ac_session_load_ratio)) * 100,
# Load progression characteristics
avg_week_to_week_change = mean(abs(distance_change), na.rm = TRUE),
progressive_weeks = sum(distance_change > 5, na.rm = TRUE),
maintenance_weeks = sum(abs(distance_change) <= 5, na.rm = TRUE),
reduction_weeks = sum(distance_change < -5, na.rm = TRUE),
.groups = 'drop'
) %>%
mutate(
# Phase efficiency metrics
load_efficiency = avg_weekly_distance / avg_weekly_sessions,
intensity_balance = avg_hsr_percentage / (distance_cv + 1), # Lower CV = better balance
periodization_quality = optimal_acwr_percentage / (distance_cv + 1) # High ACWR optimality, low variability
)
}
# Phase comparison visualization
output$phase_comparison <- renderPlotly({
phase_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(season_phase, avg_weekly_distance, avg_weekly_hsr,
avg_acwr, optimal_acwr_percentage, distance_cv) %>%
pivot_longer(-season_phase, names_to = "metric", values_to = "value")
# Normalize values for radar chart comparison
phase_data <- phase_data %>%
group_by(metric) %>%
mutate(
value_normalized = (value - min(value, na.rm = TRUE)) /
(max(value, na.rm = TRUE) - min(value, na.rm = TRUE)) * 100
) %>%
ungroup()
p <- ggplot(phase_data, aes(x = metric, y = value_normalized, fill = season_phase)) +
geom_col(position = "dodge", alpha = 0.8) +
scale_fill_viridis_d(option = "plasma") +
labs(
title = "Seasonal Phase Characteristics Comparison",
subtitle = "Normalized values (0-100 scale) for key periodization metrics",
x = "Metric",
y = "Normalized Value",
fill = "Season Phase"
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom"
)
ggplotly(p)
})
# Phase effectiveness table
output$phase_effectiveness_table <- renderDT({
effectiveness_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(
season_phase, weeks_in_phase, avg_weekly_distance, avg_weekly_hsr,
avg_acwr, optimal_acwr_percentage, distance_cv, periodization_quality
) %>%
mutate(
effectiveness_rating = case_when(
periodization_quality > 75 ~ "Excellent",
periodization_quality > 50 ~ "Good",
periodization_quality > 25 ~ "Moderate",
TRUE ~ "Needs Improvement"
)
)
datatable(
effectiveness_data,
options = list(
pageLength = 10,
scrollX = TRUE,
dom = 't'
),
colnames = c(
"Phase", "Weeks", "Avg Distance", "Avg HSR",
"Avg ACWR", "Optimal ACWR %", "Distance CV%",
"Quality Score", "Rating"
)
) %>%
formatRound(columns = 3:8, digits = 1) %>%
formatStyle(
"effectiveness_rating",
backgroundColor = styleEqual(
c("Excellent", "Good", "Moderate", "Needs Improvement"),
c("#d5e8d4", "#fff2cc", "#ffe6cc", "#f8cecc")
)
) %>%
formatStyle(
"optimal_acwr_percentage",
backgroundColor = styleInterval(c(60, 80), c("#ffcccc", "#ffffcc", "#ccffcc"))
)
})Objective 4: Analyse Team-Wide Load Patterns
Why This Matters
Team-wide analysis reveals systemic patterns that individual player analysis might miss. Understanding collective load patterns helps practitioners identify:
Squad-wide periodisation effectiveness: Are load patterns consistent across players?
Resource allocation issues: Are some players consistently over- or under-loaded?
Systemic risk factors: Are there team-wide trends that increase injury risk?
Training group optimisation: Should players be grouped differently based on load response?
The Implementation
# Team pattern analysis with advanced clustering
analyze_team_patterns <- function(data) {
# Calculate team-wide weekly summaries
team_weekly <- data %>%
group_by(week_start, season_phase) %>%
summarise(
players_active = n(),
team_total_distance = sum(total_distance, na.rm = TRUE),
team_avg_distance = mean(total_distance, na.rm = TRUE),
team_distance_cv = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
team_total_hsr = sum(total_hsr, na.rm = TRUE),
team_avg_hsr = mean(total_hsr, na.rm = TRUE),
team_hsr_cv = sd(total_hsr, na.rm = TRUE) / mean(total_hsr, na.rm = TRUE) * 100,
team_avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
high_risk_players = sum(ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5, na.rm = TRUE),
optimal_players = sum(ac_session_load_ratio >= 0.8 & ac_session_load_ratio <= 1.3, na.rm = TRUE),
# Team load distribution metrics
load_gini = Gini(total_distance), # Load inequality measure
max_min_ratio = max(total_distance, na.rm = TRUE) / min(total_distance, na.rm = TRUE),
.groups = 'drop'
) %>%
arrange(week_start) %>%
mutate(
# Team progression metrics
team_load_change = (team_avg_distance - lag(team_avg_distance)) / lag(team_avg_distance) * 100,
team_consistency = abs(team_distance_cv - lag(team_distance_cv)),
risk_trend = high_risk_players - lag(high_risk_players)
)
return(team_weekly)
}
# Team pattern visualization
output$team_pattern_analysis <- renderPlotly({
team_data <- analyze_team_patterns(filtered_weekly_data())
# Create multi-panel plot
p1 <- ggplot(team_data, aes(x = week_start)) +
geom_line(aes(y = team_avg_distance, color = "Team Average"), size = 1.2) +
geom_ribbon(aes(ymin = team_avg_distance - (team_distance_cv * team_avg_distance / 100),
ymax = team_avg_distance + (team_distance_cv * team_avg_distance / 100)),
alpha = 0.2, fill = "blue") +
labs(title = "Team Load Progression with Variability Band",
x = "Week", y = "Average Distance", color = "Metric") +
theme_minimal()
p2 <- ggplot(team_data, aes(x = week_start)) +
geom_col(aes(y = high_risk_players), fill = "#e74c3c", alpha = 0.7) +
geom_line(aes(y = optimal_players, color = "Optimal Players"), size = 1.2) +
labs(title = "Risk Profile Evolution",
x = "Week", y = "Number of Players", color = "Category") +
theme_minimal()
subplot(
ggplotly(p1),
ggplotly(p2),
nrows = 2,
shareX = TRUE,
titleY = TRUE
) %>%
layout(title = "Team-Wide Load Pattern Analysis")
})
# Load distribution analysis
output$load_distribution_analysis <- renderPlotly({
distribution_data <- filtered_weekly_data() %>%
group_by(week_start) %>%
summarise(
load_gini = Gini(total_distance),
max_load = max(total_distance, na.rm = TRUE),
min_load = min(total_distance, na.rm = TRUE),
load_range = max_load - min_load,
players_above_avg = sum(total_distance > mean(total_distance, na.rm = TRUE)),
players_below_avg = n() - players_above_avg,
.groups = 'drop'
)
p <- ggplot(distribution_data, aes(x = week_start)) +
geom_line(aes(y = load_gini * 10000, color = "Gini Coefficient (x10K)"), size = 1.2) +
geom_line(aes(y = load_range / 100, color = "Load Range (/100)"), size = 1.2) +
scale_color_manual(values = c("Gini Coefficient (x10K)" = "#e74c3c", "Load Range (/100)" = "#3498db")) +
labs(
title = "Team Load Distribution Analysis",
subtitle = "Lower Gini = more equal distribution; Lower range = more consistent loads",
x = "Week Starting",
y = "Scaled Values",
color = "Metric"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p)
})Objective 5: Provide Load Planning Tools
Why This Matters
Effective periodisation requires planning based on historical data and current status. Load planning tools enable practitioners to:
Set evidence-based targets: What loads should we aim for next week?
Plan progressive overload: How should loads progress over time?
Identify optimal timing: When should we implement high-intensity blocks?
Prevent problematic patterns: How can we avoid risk situations before they occur?
The Implementation
# Load planning recommendation system
generate_load_recommendations <- function(data) {
current_data <- data %>%
group_by(`Player ID`) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
ungroup()
recommendations <- current_data %>%
mutate(
# Historical context
historical_avg = map_dbl(`Player ID`, ~{
player_history <- data %>% filter(`Player ID` == .x)
mean(player_history$total_distance, na.rm = TRUE)
}),
# Recent trend (last 3 weeks)
recent_trend = map_dbl(`Player ID`, ~{
player_recent <- data %>%
filter(`Player ID` == .x) %>%
arrange(desc(week_start)) %>%
slice_head(n = 3)
if(nrow(player_recent) >= 2) {
(player_recent$total_distance[1] - player_recent$total_distance[3]) /
player_recent$total_distance[3] * 100
} else { 0 }
}),
# Calculate recommended next week load
recommended_distance = case_when(
# High ACWR - reduce load
ac_session_load_ratio > 1.3 ~ pmax(total_distance * 0.8, historical_avg * 0.7),
# Low ACWR - increase load gradually
ac_session_load_ratio < 0.8 ~ pmin(total_distance * 1.2, historical_avg * 1.1),
# Optimal ACWR - maintain or slight progression
recent_trend < -10 ~ total_distance * 1.1, # If declining trend, increase
recent_trend > 20 ~ total_distance * 0.95, # If steep increase, slight reduction
TRUE ~ total_distance * 1.02 # Slight progression
),
# Generate specific recommendations
recommendation_type = case_when(
ac_session_load_ratio > 1.5 ~ "Load Reduction Required",
ac_session_load_ratio > 1.3 ~ "Moderate Load Reduction",
ac_session_load_ratio < 0.5 ~ "Gradual Load Increase",
ac_session_load_ratio < 0.8 ~ "Load Progression Needed",
abs(recent_trend) > 25 ~ "Stabilize Load Pattern",
TRUE ~ "Maintain Current Progression"
),
priority_level = case_when(
ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5 ~ "High",
ac_session_load_ratio > 1.3 | ac_session_load_ratio < 0.8 ~ "Medium",
abs(recent_trend) > 20 ~ "Medium",
TRUE ~ "Low"
),
# Calculate target ranges
target_min = recommended_distance * 0.9,
target_max = recommended_distance * 1.1
) %>%
select(
`Player ID`, total_distance, ac_session_load_ratio, recent_trend,
recommended_distance, target_min, target_max,
recommendation_type, priority_level
)
return(recommendations)
}
# Load planning interface
output$load_planning_table <- renderDT({
planning_data <- generate_load_recommendations(filtered_weekly_data())
datatable(
planning_data,
options = list(
pageLength = 15,
scrollX = TRUE,
order = list(list(8, 'desc')) # Sort by priority
),
colnames = c(
"Player", "Current Load", "Current ACWR", "Recent Trend (%)",
"Recommended Load", "Target Min", "Target Max",
"Recommendation", "Priority"
)
) %>%
formatRound(columns = 2:7, digits = 0) %>%
formatRound(columns = c(3, 4), digits = 2) %>%
formatStyle(
"priority_level",
backgroundColor = styleEqual(c("High", "Medium", "Low"),
c("#f8cecc", "#fff2cc", "#d5e8d4"))
) %>%
formatStyle(
"ac_session_load_ratio",
backgroundColor = styleInterval(c(0.8, 1.3), c("#ffcccc", "#ccffcc", "#ffcccc"))
) %>%
formatStyle(
"recent_trend",
backgroundColor = styleInterval(c(-10, 10), c("#cce5ff", "#ffffff", "#ffcccc"))
)
})
# Target achievement visualization
output$target_achievement <- renderPlotly({
target_data <- filtered_weekly_data() %>%
mutate(
target_distance = case_when(
season_phase == "Pre-Season" ~ 8000,
season_phase == "Early Season" ~ 7500,
season_phase == "Mid Season" ~ 7000,
season_phase == "End Season" ~ 6500,
TRUE ~ 7000
),
achievement_rate = (total_distance / target_distance) * 100,
target_category = case_when(
achievement_rate >= 90 & achievement_rate <= 110 ~ "On Target",
achievement_rate > 110 ~ "Above Target",
achievement_rate >= 80 ~ "Below Target",
TRUE ~ "Well Below Target"
)
)
p <- ggplot(target_data, aes(x = week_start, y = achievement_rate, color = `Player ID`)) +
geom_line(size = 1, alpha = 0.7) +
geom_point(aes(shape = target_category), size = 2, alpha = 0.8) +
geom_hline(yintercept = c(90, 110), linetype = "dashed", alpha = 0.6) +
geom_ribbon(aes(ymin = 90, ymax = 110, group = 1), alpha = 0.1, fill = "green") +
scale_color_viridis_d(option = "plasma") +
scale_shape_manual(
values = c("On Target" = 16, "Above Target" = 17, "Below Target" = 15, "Well Below Target" = 4)
) +
labs(
title = "Target Achievement Analysis",
subtitle = "Green band represents optimal target achievement (90-110%)",
x = "Week Starting",
y = "Target Achievement (%)",
color = "Player",
shape = "Achievement Category"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p, tooltip = c("x", "y", "colour", "shape"))
})Objective 6: Enable Comprehensive Weekly Progression Tracking
Why This Matters
Long-term progression tracking enables practitioners to validate periodisation strategies and identify successful patterns. Key benefits include:
Strategy validation: Which periodisation approaches actually work?
Individual optimisation: What progression patterns suit each player?
Predictive insights: Can we predict performance outcomes from load patterns?
Continuous improvement: How can we refine our periodisation approach?
The Implementation
# Advanced progression analysis with predictive elements
calculate_progression_metrics <- function(data) {
data %>%
group_by(`Player ID`) %>%
arrange(week_start) %>%
mutate(
# Trend analysis using linear regression
week_number = row_number(),
distance_trend = map_dbl(week_number, ~{
if(.x >= 4) {
recent_data <- tail(data.frame(x = week_number, y = total_distance), .x)
coef(lm(y ~ x, data = recent_data))[2] # Slope coefficient
} else { NA_real_ }
}),
# Load progression quality score
progression_quality = map_dbl(week_number, ~{
if(.x >= 4) {
recent_acwr <- tail(ac_session_load_ratio, .x)
recent_loads <- tail(total_distance, .x)
# Quality based on ACWR optimality and load consistency
acwr_quality <- sum(recent_acwr >= 0.8 & recent_acwr <= 1.3, na.rm = TRUE) / length(recent_acwr[!is.na(recent_acwr)])
load_consistency <- 1 - (sd(recent_loads, na.rm = TRUE) / mean(recent_loads, na.rm = TRUE))
(acwr_quality * 0.7 + load_consistency * 0.3) * 100
} else { NA_real_ }
}),
# Adaptation markers
load_adaptation = case_when(
week_number < 4 ~ "Insufficient Data",
distance_trend > 50 & progression_quality > 70 ~ "Positive Adaptation",
distance_trend < -50 & progression_quality < 50 ~ "Maladaptation",
abs(distance_trend) < 25 & progression_quality > 60 ~ "Stable Adaptation",
TRUE ~ "Variable Response"
),
# Performance prediction risk
performance_risk = case_when(
progression_quality < 40 ~ "High Risk",
progression_quality < 60 ~ "Moderate Risk",
progression_quality < 80 ~ "Low Risk",
TRUE ~ "Optimal"
)
) %>%
ungroup()
}
# Progression tracking visualization
output$progression_tracking <- renderPlotly({
progression_data <- calculate_progression_metrics(filtered_weekly_data()) %>%
filter(!is.na(progression_quality))
p <- ggplot(progression_data, aes(x = week_start, y = progression_quality, color = `Player ID`)) +
geom_line(size = 1.2, alpha = 0.8) +
geom_point(aes(shape = performance_risk), size = 2.5, alpha = 0.9) +
geom_hline(yintercept = c(60, 80), linetype = "dashed", alpha = 0.6) +
geom_ribbon(aes(ymin = 60, ymax = 80, group = 1), alpha = 0.1, fill = "blue") +
scale_color_viridis_d(option = "plasma") +
scale_shape_manual(
values = c("Optimal" = 16, "Low Risk" = 17, "Moderate Risk" = 15, "High Risk" = 4)
) +
labs(
title = "Weekly Progression Quality Tracking",
subtitle = "Quality score based on ACWR optimality and load consistency",
x = "Week Starting",
y = "Progression Quality Score",
color = "Player",
shape = "Performance Risk"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p, tooltip = c("x", "y", "colour", "shape"))
})
# Progression summary table
output$progression_summary_table <- renderDT({
summary_data <- calculate_progression_metrics(filtered_weekly_data()) %>%
group_by(`Player ID`) %>%
filter(!is.na(progression_quality)) %>%
summarise(
weeks_tracked = n(),
avg_progression_quality = round(mean(progression_quality, na.rm = TRUE), 1),
current_progression_quality = round(tail(progression_quality, 1), 1),
avg_distance_trend = round(mean(distance_trend, na.rm = TRUE), 1),
current_adaptation = tail(load_adaptation, 1),
current_risk = tail(performance_risk, 1),
optimal_weeks = sum(progression_quality >= 80, na.rm = TRUE),
risk_weeks = sum(progression_quality < 60, na.rm = TRUE),
.groups = 'drop'
) %>%
arrange(desc(avg_progression_quality))
datatable(
summary_data,
options = list(
pageLength = 15,
scrollX = TRUE,
order = list(list(2, 'desc'))
),
colnames = c(
"Player", "Weeks", "Avg Quality", "Current Quality",
"Avg Trend", "Adaptation", "Risk Level", "Optimal Weeks", "Risk Weeks"
)
) %>%
formatRound(columns = c(3, 4, 5), digits = 1) %>%
formatStyle(
"current_risk",
backgroundColor = styleEqual(c("Optimal", "Low Risk", "Moderate Risk", "High Risk"),
c("#d5e8d4", "#fff2cc", "#ffe6cc", "#f8cecc"))
) %>%
formatStyle(
"current_progression_quality",
backgroundColor = styleInterval(c(60, 80), c("#ffcccc", "#ffffcc", "#ccffcc"))
)
})Advanced Features and Insights
Load Monotony and Training Strain Analysis
Building on Foster’s work, we can assess training monotony (lack of variation) and training strain (monotony × load):
# Advanced monotony analysis
output$monotony_analysis <- renderPlotly({
monotony_data <- filtered_weekly_data() %>%
group_by(`Player ID`) %>%
arrange(week_start) %>%
mutate(
rolling_monotony = rollmean(load_monotony, k = 4, fill = NA, align = "right"),
rolling_strain = rollmean(training_strain, k = 4, fill = NA, align = "right")
) %>%
filter(!is.na(rolling_monotony))
p <- ggplot(monotony_data, aes(x = rolling_monotony, y = rolling_strain, color = `Player ID`)) +
geom_point(size = 3, alpha = 0.7) +
geom_smooth(method = "lm", se = FALSE, alpha = 0.6) +
scale_color_viridis_d(option = "plasma") +
labs(
title = "Training Monotony vs Strain Analysis",
subtitle = "Higher values indicate potential overtraining risk",
x = "Training Monotony (4-week rolling average)",
y = "Training Strain (4-week rolling average)",
color = "Player"
) +
theme_minimal()
ggplotly(p)
})Periodisation Efficiency Metrics
# Calculate periodization efficiency
calculate_periodization_efficiency <- function(data) {
data %>%
group_by(`Player ID`) %>%
summarise(
# Load distribution efficiency
load_distribution_score = 100 - (sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100),
# ACWR management efficiency
acwr_efficiency = sum(ac_session_load_ratio >= 0.8 & ac_session_load_ratio <= 1.3, na.rm = TRUE) /
sum(!is.na(ac_session_load_ratio)) * 100,
# Progressive overload efficiency
positive_progression_weeks = sum(distance_change > 0 & distance_change <= 15, na.rm = TRUE),
total_progression_weeks = sum(!is.na(distance_change)),
progression_efficiency = (positive_progression_weeks / total_progression_weeks) * 100,
# Overall periodization score
overall_efficiency = (load_distribution_score * 0.3 + acwr_efficiency * 0.4 + progression_efficiency * 0.3),
.groups = 'drop'
) %>%
mutate(
efficiency_grade = case_when(
overall_efficiency >= 85 ~ "Excellent",
overall_efficiency >= 75 ~ "Good",
overall_efficiency >= 65 ~ "Satisfactory",
TRUE ~ "Needs Improvement"
)
)
}Summary
We’ve successfully created a comprehensive Weekly Load Periodisation Tracker that transforms weekly training data into actionable periodisation insights. Our system provides:
Comprehensive weekly load aggregation with sophisticated rolling metrics and trend analysis
Advanced AC ratio monitoring with automated risk zone identification and recommendations
Seasonal periodisation tracking with effectiveness assessment across training phases
Team-wide pattern analysis revealing optimisation opportunities and consistency metrics
Evidence-based load planning tools with individualised targets and structure recommendations
Multi-dimensional progression tracking with visual trend analysis and comparative assessment
Key Takeaways
Weekly aggregation reveals hidden patterns: Daily session noise disappears, revealing true periodisation effectiveness
AC ratios provide actionable risk insights: The optimal zone (0.8-1.3) significantly reduces injury risk
Periodisation phases require different approaches: What works in pre-season may not work mid-season
Team patterns reveal systemic issues: Consistency and variability metrics guide program optimisation
Individual targets beat one-size-fits-all: Historical performance data enables personalised load management
Progression tracking validates training decisions: Longitudinal analysis confirms or refutes periodisation strategies
Practical Applications
This weekly periodisation tracking enables practitioners to:
Optimise microcycle structure by understanding load distribution patterns and their effects
Prevent overreaching and injury through proactive AC ratio monitoring and risk assessment
Validate periodisation strategies by analysing phase-specific load characteristics and effectiveness
Improve squad-wide consistency by identifying and addressing systematic load management issues
Plan evidence-based training progressions using individualised targets derived from historical performance
Make informed weekly adjustments based on comprehensive trend analysis and risk indicators
Implementation Considerations
When implementing this system:
Start with basic aggregation before adding complex features
Validate ACWR thresholds against your population’s injury data
Customise phase definitions based on your competitive calendar
Train stakeholders on interpretation and application
Iterate based on feedback from coaches and athletes
Monitor system effectiveness through outcome tracking
Next Steps
In Part 6 of our series, we’ll explore Recovery and Readiness Monitoring, examining how to integrate subjective wellness measures with objective load data to optimise training adaptation and performance readiness.
This is Part 5 of our 7-part Athlete Monitoring Series. Coming up: Recovery Monitoring and Risk Assessment.
References
Torfs, S. (2024). How Lommel SK balances team and individual priorities to determine training loads. Sportsmith.
Jennings, J. (2024). Building Your Own Monitoring Tool to Track Any Metric Over Time. Sportsmith.
Gabbett, T. J., & Oetter, E. (2024). From Tissue to System: What Constitutes an Appropriate Response to Loading? Sports Medicine.
Clubb, J. (2024). Training Load Monitoring and the Goldilocks Strategy. Sportsmith.
Blanch, P., & Gabbett, T. J. (2016). Has the athlete trained enough to return to play safely? The acute:chronic workload ratio permits clinicians to quantify a player’s risk of subsequent injury. British Journal of Sports Medicine, 50(8), 471-475.
Foster, C. (1998). Monitoring training in athletes with reference to overtraining syndrome. Medicine & Science in Sports & Exercise, 30(7), 1164-1168.
# Blog 5: Weekly Load Periodisation Tracking - Option A
# Load required libraries
library(shiny)
library(shinydashboard)
library(DT)
library(ggplot2)
library(plotly)
library(dplyr)
library(readr)
library(lubridate)
library(zoo)
library(viridis)
library(RColorBrewer)
library(tidyr)
library(stringr)
library(scales)
library(purrr)
# Load and preprocess data
print("Loading and preprocessing data for weekly load periodisation tracking...")
# Read the data - update path as needed
training_data <- read_csv("soccer_training_load_final_corrected.csv")
# Enhanced data preprocessing for weekly analysis
training_data <- training_data %>%
mutate(
Date = dmy(Date),
player_id = as.character(`Player ID`), # Ensure consistent naming and type
session_type = `Session Day Type`,
md_day = `MD Relative`,
duration = `Duration (min)`,
total_distance = `Distance (m)`,
distance_per_min = `Distance per min (m)`,
hsr_distance = `High speed running (m) (19,8 – 25,2 km/h)`,
sprint_distance = `Sprinting (m) (>25,2km/h)`,
accelerations = `Accelerations (>3m/s2)`,
decelerations = `Decelerations (>3m/s2)`,
max_speed = `Max speed (km/h)`,
# Weekly grouping variables
week_start = floor_date(Date, "week", week_start = 1), # Monday start
week_number = isoweek(Date),
year = year(Date),
week_year = paste(year, sprintf("%02d", week_number), sep = "-W"),
# Composite load metrics
session_load = duration * distance_per_min,
high_intensity_distance = hsr_distance + sprint_distance,
# Training stress score (weighted by session intensity)
training_stress = case_when(
session_type == "Match Day" ~ session_load * 1.5,
session_type == "Physical Training" ~ session_load * 1.2,
session_type == "Tactical Training" ~ session_load * 1.0,
session_type == "Pre-Match Training" ~ session_load * 0.7,
session_type == "Recovery Training" ~ session_load * 0.5,
TRUE ~ session_load
),
# Season phase classification
season_phase = case_when(
month(Date) %in% c(6, 7, 8) ~ "Pre-Season",
month(Date) %in% c(9, 10, 11, 12) ~ "Early Season",
month(Date) %in% c(1, 2, 3) ~ "Mid Season",
month(Date) %in% c(4, 5) ~ "End Season",
TRUE ~ "Transition"
)
) %>%
filter(!is.na(Date), !is.na(player_id), player_id != "")
print(paste("Data loaded successfully:", nrow(training_data), "rows"))
# Calculate comprehensive weekly metrics
calculate_weekly_metrics <- function(data) {
data %>%
group_by(player_id, week_start, week_year, season_phase) %>%
summarise(
# Session counts and composition
total_sessions = n(),
training_sessions = sum(session_type != "Match Day"),
match_sessions = sum(session_type == "Match Day"),
recovery_sessions = sum(session_type == "Recovery Training"),
physical_sessions = sum(session_type == "Physical Training"),
tactical_sessions = sum(session_type == "Tactical Training"),
# Volume metrics (absolute weekly totals)
total_duration = sum(duration, na.rm = TRUE),
total_distance = sum(total_distance, na.rm = TRUE),
total_hsr = sum(hsr_distance, na.rm = TRUE),
total_sprint = sum(sprint_distance, na.rm = TRUE),
total_accelerations = sum(accelerations, na.rm = TRUE),
total_decelerations = sum(decelerations, na.rm = TRUE),
# Intensity metrics (weekly averages)
avg_distance_per_min = mean(distance_per_min, na.rm = TRUE),
max_speed_week = max(max_speed, na.rm = TRUE),
total_session_load = sum(session_load, na.rm = TRUE),
total_training_stress = sum(training_stress, na.rm = TRUE),
# Derived weekly metrics
hsr_percentage = (total_hsr / total_distance) * 100,
sprint_percentage = (total_sprint / total_distance) * 100,
avg_session_duration = mean(duration, na.rm = TRUE),
# Weekly load distribution patterns
sessions_per_day = total_sessions / 7,
training_density = training_sessions / 7,
.groups = "drop"
) %>%
# Calculate rolling metrics and ratios
group_by(player_id) %>%
arrange(week_start) %>%
mutate(
# 4-week chronic load (rolling average)
chronic_distance = rollmean(total_distance, k = 4, fill = NA, align = "right"),
chronic_hsr = rollmean(total_hsr, k = 4, fill = NA, align = "right"),
chronic_session_load = rollmean(total_session_load, k = 4, fill = NA, align = "right"),
chronic_training_stress = rollmean(total_training_stress, k = 4, fill = NA, align = "right"),
# Acute:Chronic ratios (current week / 4-week average)
ac_distance_ratio = total_distance / chronic_distance,
ac_hsr_ratio = total_hsr / chronic_hsr,
ac_session_load_ratio = total_session_load / chronic_session_load,
ac_training_stress_ratio = total_training_stress / chronic_training_stress,
# Week-to-week percentage changes
distance_change = (total_distance - lag(total_distance)) / lag(total_distance) * 100,
hsr_change = (total_hsr - lag(total_hsr)) / lag(total_hsr) * 100,
session_load_change = (total_session_load - lag(total_session_load)) / lag(total_session_load) * 100,
# Load monotony and strain (adapted for weekly data)
load_monotony = total_session_load / (sd(c(
total_session_load,
lag(total_session_load, 1),
lag(total_session_load, 2)
), na.rm = TRUE) + 1),
training_strain = total_training_stress * load_monotony,
# Risk categorization based on ACWR
acwr_risk_category = case_when(
ac_session_load_ratio < 0.5 ~ "Very High Risk",
ac_session_load_ratio < 0.8 ~ "Moderate Risk",
ac_session_load_ratio <= 1.3 ~ "Optimal",
ac_session_load_ratio <= 1.5 ~ "Moderate Risk",
TRUE ~ "Very High Risk"
)
) %>%
ungroup()
}
# Calculate seasonal periodisation characteristics
calculate_phase_characteristics <- function(data) {
data %>%
group_by(season_phase) %>%
summarise(
# Basic characteristics
weeks_in_phase = n_distinct(week_start),
total_observations = n(),
avg_players_per_week = n() / n_distinct(week_start),
# Load characteristics
avg_weekly_distance = mean(total_distance, na.rm = TRUE),
avg_weekly_hsr = mean(total_hsr, na.rm = TRUE),
avg_weekly_sessions = mean(total_sessions, na.rm = TRUE),
avg_training_density = mean(training_density, na.rm = TRUE),
# Intensity characteristics
avg_session_intensity = mean(avg_distance_per_min, na.rm = TRUE),
avg_hsr_percentage = mean(hsr_percentage, na.rm = TRUE),
avg_max_speed = mean(max_speed_week, na.rm = TRUE),
# Load variability within phase
distance_cv = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
hsr_cv = sd(total_hsr, na.rm = TRUE) / mean(total_hsr, na.rm = TRUE) * 100,
# ACWR characteristics
avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
acwr_above_13 = sum(ac_session_load_ratio > 1.3, na.rm = TRUE),
acwr_below_08 = sum(ac_session_load_ratio < 0.8, na.rm = TRUE),
optimal_acwr_percentage = sum(ac_session_load_ratio >= 0.8 &
ac_session_load_ratio <= 1.3, na.rm = TRUE) /
sum(!is.na(ac_session_load_ratio)) * 100,
# Load progression characteristics
avg_week_to_week_change = mean(abs(distance_change), na.rm = TRUE),
progressive_weeks = sum(distance_change > 5, na.rm = TRUE),
maintenance_weeks = sum(abs(distance_change) <= 5, na.rm = TRUE),
reduction_weeks = sum(distance_change < -5, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
# Phase efficiency metrics
load_efficiency = avg_weekly_distance / avg_weekly_sessions,
intensity_balance = avg_hsr_percentage / (distance_cv + 1),
periodisation_quality = optimal_acwr_percentage / (distance_cv + 1)
)
}
# Generate load planning recommendations
generate_load_recommendations <- function(weekly_data) {
current_data <- weekly_data %>%
group_by(player_id) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
ungroup()
recommendations <- current_data %>%
mutate(
# Historical context
historical_avg = map_dbl(player_id, ~ {
player_history <- weekly_data %>% filter(player_id == .x)
mean(player_history$total_distance, na.rm = TRUE)
}),
# Recent trend (last 3 weeks)
recent_trend = map_dbl(player_id, ~ {
player_recent <- weekly_data %>%
filter(player_id == .x) %>%
arrange(desc(week_start)) %>%
slice_head(n = 3)
if (nrow(player_recent) >= 2) {
(player_recent$total_distance[1] - player_recent$total_distance[3]) /
player_recent$total_distance[3] * 100
} else {
0
}
}),
# Calculate recommended next week load
recommended_distance = case_when(
# High ACWR - reduce load
ac_session_load_ratio > 1.3 ~ pmax(total_distance * 0.8, historical_avg * 0.7),
# Low ACWR - increase load gradually
ac_session_load_ratio < 0.8 ~ pmin(total_distance * 1.2, historical_avg * 1.1),
# Optimal ACWR - maintain or slight progression
recent_trend < -10 ~ total_distance * 1.1, # If declining trend, increase
recent_trend > 20 ~ total_distance * 0.95, # If steep increase, slight reduction
TRUE ~ total_distance * 1.02 # Slight progression
),
# Generate specific recommendations
recommendation_type = case_when(
ac_session_load_ratio > 1.5 ~ "Load Reduction Required",
ac_session_load_ratio > 1.3 ~ "Moderate Load Reduction",
ac_session_load_ratio < 0.5 ~ "Gradual Load Increase",
ac_session_load_ratio < 0.8 ~ "Load Progression Needed",
abs(recent_trend) > 25 ~ "Stabilize Load Pattern",
TRUE ~ "Maintain Current Progression"
),
priority_level = case_when(
ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5 ~ "High",
ac_session_load_ratio > 1.3 | ac_session_load_ratio < 0.8 ~ "Medium",
abs(recent_trend) > 20 ~ "Medium",
TRUE ~ "Low"
),
# Calculate target ranges
target_min = recommended_distance * 0.9,
target_max = recommended_distance * 1.1
) %>%
select(
player_id, total_distance, ac_session_load_ratio, recent_trend,
recommended_distance, target_min, target_max,
recommendation_type, priority_level
)
return(recommendations)
}
print("Calculating weekly metrics...")
weekly_data <- calculate_weekly_metrics(training_data)
phase_characteristics <- calculate_phase_characteristics(weekly_data)
print(paste("Weekly periodisation analysis complete:", nrow(weekly_data), "weekly observations"))
# Define UI for Weekly Load Periodisation Tracking
ui <- dashboardPage(
dashboardHeader(title = "Weekly Load Periodisation Tracker"),
dashboardSidebar(
sidebarMenu(
menuItem("Weekly Overview", tabName = "overview", icon = icon("calendar-week")),
menuItem("ACWR Monitoring", tabName = "acwr", icon = icon("balance-scale")),
menuItem("Periodisation Analysis", tabName = "periodisation", icon = icon("chart-line")),
menuItem("Load Planning", tabName = "planning", icon = icon("clipboard-list"))
),
# Filters
selectInput("selected_players_weekly", "Players:",
choices = unique(weekly_data$player_id),
selected = unique(weekly_data$player_id)[1:8],
multiple = TRUE
),
dateRangeInput("date_range_weekly", "Week Range:",
start = min(weekly_data$week_start, na.rm = TRUE),
end = max(weekly_data$week_start, na.rm = TRUE)
),
selectInput("primary_metric_weekly", "Primary Metric:",
choices = c(
"total_distance" = "total_distance",
"total_hsr" = "total_hsr",
"total_session_load" = "total_session_load",
"total_training_stress" = "total_training_stress"
),
selected = "total_distance"
),
checkboxInput("show_team_avg", "Show Team Average", value = TRUE),
selectInput("phase_filter_weekly", "Season Phase:",
choices = c("All", unique(weekly_data$season_phase)),
selected = "All"
)
),
dashboardBody(
tabItems(
# Weekly Overview Tab
tabItem(
tabName = "overview",
fluidRow(
valueBoxOutput("total_weeks"),
valueBoxOutput("avg_weekly_distance"),
valueBoxOutput("avg_weekly_sessions")
),
fluidRow(
box(
title = "Weekly Load Distribution", status = "primary", solidHeader = TRUE,
width = 12, height = 450,
plotlyOutput("weekly_load_distribution")
)
),
fluidRow(
box(
title = "Load Variability Analysis", status = "info", solidHeader = TRUE,
width = 6,
plotlyOutput("load_variability")
),
box(
title = "Training Composition", status = "success", solidHeader = TRUE,
width = 6,
plotlyOutput("training_composition")
)
)
),
# ACWR Monitoring Tab
tabItem(
tabName = "acwr",
fluidRow(
valueBoxOutput("avg_team_acwr"),
valueBoxOutput("high_risk_players_weekly"),
valueBoxOutput("optimal_zone_percentage")
),
fluidRow(
box(
title = "ACWR Timeline Tracking", status = "primary", solidHeader = TRUE,
width = 8,
plotlyOutput("acwr_timeline_weekly")
),
box(
title = "Current ACWR Status", status = "warning", solidHeader = TRUE,
width = 4,
plotlyOutput("current_acwr_status")
)
),
fluidRow(
box(
title = "ACWR Risk Distribution", status = "danger", solidHeader = TRUE,
width = 6,
plotlyOutput("acwr_risk_distribution")
),
box(
title = "Risk Alert Summary", status = "info", solidHeader = TRUE,
width = 6,
DTOutput("risk_alert_summary")
)
)
),
# Periodisation Analysis Tab
tabItem(
tabName = "periodisation",
fluidRow(
box(
title = "Seasonal Phase Comparison", status = "primary", solidHeader = TRUE,
width = 8,
plotlyOutput("phase_comparison")
),
box(
title = "Phase Effectiveness", status = "info", solidHeader = TRUE,
width = 4,
DTOutput("phase_effectiveness_table")
)
),
fluidRow(
box(
title = "Weekly Progression Patterns", status = "success", solidHeader = TRUE,
width = 6,
plotlyOutput("progression_patterns")
),
box(
title = "Load Monotony Analysis", status = "warning", solidHeader = TRUE,
width = 6,
plotlyOutput("monotony_analysis")
)
)
),
# Load Planning Tab
tabItem(
tabName = "planning",
fluidRow(
box(
title = "Load Planning Recommendations", status = "primary", solidHeader = TRUE,
width = 8,
DTOutput("load_planning_table")
),
box(
title = "Planning Summary", status = "info", solidHeader = TRUE,
width = 4,
valueBoxOutput("high_priority_players", width = 12),
valueBoxOutput("load_adjustments_needed", width = 12),
valueBoxOutput("stable_progressions", width = 12)
)
),
fluidRow(
box(
title = "Target Achievement Analysis", status = "success", solidHeader = TRUE,
width = 12,
plotlyOutput("target_achievement")
)
)
)
)
)
)
# Define server logic
server <- function(input, output, session) {
# Reactive data filtering
filtered_weekly_data <- reactive({
data <- weekly_data %>%
filter(
player_id %in% input$selected_players_weekly,
week_start >= input$date_range_weekly[1],
week_start <= input$date_range_weekly[2]
)
# Apply phase filter if not "All"
if (input$phase_filter_weekly != "All") {
data <- data %>%
filter(season_phase == input$phase_filter_weekly)
}
data
})
# Generate load recommendations
load_recommendations <- reactive({
generate_load_recommendations(filtered_weekly_data())
})
# Value boxes for overview
output$total_weeks <- renderValueBox({
n_weeks <- length(unique(filtered_weekly_data()$week_start))
valueBox(
value = n_weeks,
subtitle = "Weeks Monitored",
icon = icon("calendar-week"),
color = "blue"
)
})
output$avg_weekly_distance <- renderValueBox({
avg_dist <- round(mean(filtered_weekly_data()$total_distance, na.rm = TRUE), 0)
valueBox(
value = paste0(format(avg_dist, big.mark = ","), "m"),
subtitle = "Avg Weekly Distance",
icon = icon("route"),
color = "green"
)
})
output$avg_weekly_sessions <- renderValueBox({
avg_sessions <- round(mean(filtered_weekly_data()$total_sessions, na.rm = TRUE), 1)
valueBox(
value = avg_sessions,
subtitle = "Avg Sessions/Week",
icon = icon("list-ol"),
color = "yellow"
)
})
# Weekly load distribution
output$weekly_load_distribution <- renderPlotly({
plot_data <- filtered_weekly_data() %>%
filter(!is.na(week_start), !is.na(player_id)) %>%
mutate(week_start = as.Date(week_start)) %>%
arrange(week_start)
if (nrow(plot_data) == 0) {
p <- ggplot() +
labs(title = "No data available") +
theme_minimal()
ggplotly(p)
} else {
p <- ggplot(plot_data, aes(
x = week_start, y = .data[[input$primary_metric_weekly]],
color = player_id
)) +
geom_line(linewidth = 1.2, alpha = 0.8) +
geom_point(size = 2.5, alpha = 0.9) +
scale_color_viridis_d(option = "plasma") +
scale_x_date(date_labels = "%Y-%m-%d", date_breaks = "1 week") +
labs(
title = paste("Weekly", str_to_title(gsub("_", " ", input$primary_metric_weekly)), "Progression"),
subtitle = paste(
"Tracking", length(input$selected_players_weekly), "players over",
length(unique(plot_data$week_start)), "weeks"
),
x = "Week Starting",
y = str_to_title(gsub("_", " ", input$primary_metric_weekly)),
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(size = 14, face = "bold"),
axis.text = element_text(size = 10),
axis.text.x = element_text(angle = 45, hjust = 1)
)
# Add team average line if requested
if (input$show_team_avg) {
team_avg_data <- plot_data %>%
group_by(week_start) %>%
summarise(
team_avg = mean(.data[[input$primary_metric_weekly]], na.rm = TRUE),
.groups = "drop"
)
p <- p + geom_line(
data = team_avg_data, aes(x = week_start, y = team_avg, color = NULL),
color = "black", linewidth = 2, linetype = "dashed", alpha = 0.8,
inherit.aes = FALSE
)
}
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
}
})
# Load variability analysis
output$load_variability <- renderPlotly({
variability_data <- filtered_weekly_data() %>%
group_by(player_id) %>%
summarise(
cv_distance = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
cv_hsr = sd(total_hsr, na.rm = TRUE) / mean(total_hsr, na.rm = TRUE) * 100,
.groups = "drop"
)
p <- ggplot(variability_data, aes(x = cv_distance, y = cv_hsr)) +
geom_point(size = 3, alpha = 0.7, color = "#e74c3c") +
geom_text(aes(label = player_id), vjust = -0.5, size = 3) +
labs(
title = "Load Variability Analysis",
subtitle = "Coefficient of variation - lower values indicate more consistent loading",
x = "Distance Variability (CV%)",
y = "HSR Variability (CV%)"
) +
theme_minimal()
ggplotly(p)
})
# Training composition
output$training_composition <- renderPlotly({
composition_data <- filtered_weekly_data() %>%
group_by(week_start) %>%
summarise(
avg_tactical = mean(tactical_sessions, na.rm = TRUE),
avg_physical = mean(physical_sessions, na.rm = TRUE),
avg_recovery = mean(recovery_sessions, na.rm = TRUE),
avg_matches = mean(match_sessions, na.rm = TRUE),
.groups = "drop"
) %>%
pivot_longer(-week_start, names_to = "session_type", values_to = "sessions") %>%
mutate(
session_type = case_when(
session_type == "avg_tactical" ~ "Tactical",
session_type == "avg_physical" ~ "Physical",
session_type == "avg_recovery" ~ "Recovery",
session_type == "avg_matches" ~ "Matches"
)
)
p <- ggplot(composition_data, aes(x = week_start, y = sessions, fill = session_type)) +
geom_area(position = "stack", alpha = 0.8) +
scale_fill_viridis_d() +
labs(
title = "Weekly Training Composition",
x = "Week Starting",
y = "Average Sessions per Week",
fill = "Session Type"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p)
})
# ACWR value boxes
output$avg_team_acwr <- renderValueBox({
data <- filtered_weekly_data()
if (nrow(data) == 0 || !"ac_session_load_ratio" %in% names(data)) {
avg_acwr <- 0
} else {
avg_acwr <- data$ac_session_load_ratio %>%
mean(na.rm = TRUE) %>%
round(2)
}
color <- if (avg_acwr > 1.3) "red" else if (avg_acwr < 0.8) "orange" else "green"
valueBox(
value = avg_acwr,
subtitle = "Team Average ACWR",
icon = icon("balance-scale"),
color = color
)
})
output$high_risk_players_weekly <- renderValueBox({
data <- filtered_weekly_data()
if (nrow(data) == 0 || !"player_id" %in% names(data) || !"acwr_risk_category" %in% names(data)) {
high_risk_count <- 0
} else {
high_risk_count <- data %>%
filter(!is.na(player_id), !is.na(acwr_risk_category)) %>%
filter(acwr_risk_category %in% c("Very High Risk")) %>%
distinct(player_id) %>%
nrow()
}
color <- if (high_risk_count > 3) "red" else if (high_risk_count > 1) "yellow" else "green"
valueBox(
value = high_risk_count,
subtitle = "Very High Risk Players",
icon = icon("exclamation-triangle"),
color = color
)
})
output$optimal_zone_percentage <- renderValueBox({
data <- filtered_weekly_data()
if (nrow(data) == 0 || !"ac_session_load_ratio" %in% names(data) || !"acwr_risk_category" %in% names(data)) {
optimal_pct <- 0
} else {
optimal_pct <- data %>%
filter(!is.na(ac_session_load_ratio)) %>%
summarise(pct = sum(acwr_risk_category == "Optimal", na.rm = TRUE) / n() * 100) %>%
pull(pct) %>%
round(1)
}
color <- if (optimal_pct >= 70) "green" else if (optimal_pct >= 50) "yellow" else "red"
valueBox(
value = paste0(optimal_pct, "%"),
subtitle = "In Optimal Zone",
icon = icon("bullseye"),
color = color
)
})
# ACWR timeline
output$acwr_timeline_weekly <- renderPlotly({
plot_data <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio), !is.na(week_start), !is.na(player_id)) %>%
mutate(week_start = as.Date(week_start)) # Ensure proper Date format
if (nrow(plot_data) == 0) {
p <- ggplot() +
labs(title = "No ACWR data available") +
theme_minimal()
ggplotly(p)
} else {
p <- plot_data %>%
ggplot(aes(x = week_start, y = ac_session_load_ratio, color = player_id)) +
geom_line(linewidth = 1.2, alpha = 0.8) +
geom_point(size = 2.5, alpha = 0.9) +
# Risk zone indicators
geom_hline(yintercept = 0.8, linetype = "dashed", color = "#2ecc71", alpha = 0.8, linewidth = 1) +
geom_hline(yintercept = 1.3, linetype = "dashed", color = "#2ecc71", alpha = 0.8, linewidth = 1) +
geom_hline(yintercept = 1.5, linetype = "dashed", color = "#e74c3c", alpha = 0.8, linewidth = 1) +
# Colored risk zones
annotate("rect",
xmin = as.Date(-Inf, origin = "1970-01-01"),
xmax = as.Date(Inf, origin = "1970-01-01"),
ymin = 0.8, ymax = 1.3,
fill = "#2ecc71", alpha = 0.1
) +
annotate("rect",
xmin = as.Date(-Inf, origin = "1970-01-01"),
xmax = as.Date(Inf, origin = "1970-01-01"),
ymin = 1.5, ymax = Inf,
fill = "#e74c3c", alpha = 0.15
) +
scale_color_viridis_d() +
scale_x_date(date_labels = "%Y-%m-%d", date_breaks = "1 week") +
labs(
title = "Weekly ACWR Monitoring",
subtitle = "Optimal range: 0.8-1.3 | High risk: >1.5",
x = "Week Starting",
y = "AC Workload Ratio",
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
}
})
# Current ACWR status
output$current_acwr_status <- renderPlotly({
current_data <- filtered_weekly_data() %>%
group_by(player_id) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
arrange(desc(ac_session_load_ratio))
if (nrow(current_data) > 0) {
p <- ggplot(current_data, aes(
x = reorder(player_id, ac_session_load_ratio),
y = ac_session_load_ratio, fill = acwr_risk_category
)) +
geom_col(alpha = 0.8) +
geom_hline(yintercept = c(0.8, 1.3), linetype = "dashed", alpha = 0.6) +
scale_fill_manual(values = c(
"Optimal" = "#2ecc71", "Moderate Risk" = "#f39c12",
"Very High Risk" = "#e74c3c"
), name = "Risk") +
coord_flip() +
labs(
title = "Current ACWR Status",
x = "Player", y = "ACWR"
) +
theme_minimal()
ggplotly(p)
} else {
ggplot() +
labs(title = "No current ACWR data") +
theme_minimal()
}
})
# ACWR risk distribution
output$acwr_risk_distribution <- renderPlotly({
p <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio)) %>%
ggplot(aes(x = ac_session_load_ratio)) +
geom_histogram(bins = 30, fill = "#3498db", alpha = 0.7, color = "white") +
geom_vline(xintercept = c(0.8, 1.3), color = "red", linetype = "dashed") +
labs(
title = "ACWR Distribution",
x = "Weekly ACWR",
y = "Frequency"
) +
theme_minimal()
ggplotly(p)
})
# Risk alert summary
output$risk_alert_summary <- renderDT({
alert_data <- filtered_weekly_data() %>%
filter(acwr_risk_category %in% c("Very High Risk", "Moderate Risk")) %>%
group_by(player_id) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
select(player_id, ac_session_load_ratio, acwr_risk_category) %>%
arrange(desc(ac_session_load_ratio))
if (nrow(alert_data) > 0) {
datatable(
alert_data,
options = list(pageLength = 8, dom = "t"),
colnames = c("Player", "ACWR", "Risk Level")
) %>%
formatRound(columns = "ac_session_load_ratio", digits = 2)
} else {
datatable(data.frame(Message = "No high-risk players currently"))
}
})
# Phase comparison
output$phase_comparison <- renderPlotly({
phase_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(
season_phase, avg_weekly_distance, avg_weekly_hsr,
avg_acwr, optimal_acwr_percentage, distance_cv
) %>%
pivot_longer(-season_phase, names_to = "metric", values_to = "value")
# Normalize values for comparison
phase_data <- phase_data %>%
group_by(metric) %>%
mutate(
value_normalized = (value - min(value, na.rm = TRUE)) /
(max(value, na.rm = TRUE) - min(value, na.rm = TRUE)) * 100
) %>%
ungroup()
if (nrow(phase_data) > 0) {
p <- ggplot(phase_data, aes(x = metric, y = value_normalized, fill = season_phase)) +
geom_col(position = "dodge", alpha = 0.8) +
scale_fill_viridis_d(option = "plasma") +
labs(
title = "Seasonal Phase Characteristics",
subtitle = "Normalized values (0-100 scale)",
x = "Metric",
y = "Normalized Value",
fill = "Season Phase"
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom"
)
ggplotly(p)
} else {
ggplot() +
labs(title = "No phase data available") +
theme_minimal()
}
})
# Phase effectiveness table
output$phase_effectiveness_table <- renderDT({
effectiveness_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(
season_phase, weeks_in_phase, avg_weekly_distance,
optimal_acwr_percentage, distance_cv, periodisation_quality
) %>%
mutate(
effectiveness_rating = case_when(
periodisation_quality > 75 ~ "Excellent",
periodisation_quality > 50 ~ "Good",
periodisation_quality > 25 ~ "Moderate",
TRUE ~ "Needs Improvement"
)
)
if (nrow(effectiveness_data) > 0) {
datatable(
effectiveness_data,
options = list(pageLength = 5, dom = "t"),
colnames = c(
"Phase", "Weeks", "Avg Distance", "Optimal ACWR%",
"Distance CV%", "Quality Score", "Rating"
)
) %>%
formatRound(columns = 3:6, digits = 1)
} else {
datatable(data.frame(Message = "No phase data available"))
}
})
# Progression patterns
output$progression_patterns <- renderPlotly({
progression_data <- filtered_weekly_data() %>%
filter(!is.na(distance_change), !is.na(week_start)) %>%
mutate(
week_start = as.Date(week_start),
change_category = case_when(
distance_change > 15 ~ "Large Increase",
distance_change > 5 ~ "Moderate Increase",
distance_change > -5 ~ "Stable",
distance_change > -15 ~ "Moderate Decrease",
TRUE ~ "Large Decrease"
)
)
if (nrow(progression_data) == 0) {
p <- ggplot() +
labs(title = "No progression data available") +
theme_minimal()
return(ggplotly(p))
}
p <- ggplot(progression_data, aes(
x = week_start, y = distance_change,
color = change_category
)) +
geom_point(size = 2, alpha = 0.7) +
geom_hline(yintercept = c(-10, 10), linetype = "dashed", alpha = 0.5) +
scale_color_viridis_d() +
scale_x_date(date_labels = "%Y-%m-%d", date_breaks = "1 week") +
labs(
title = "Weekly Load Progression Patterns",
x = "Week Starting",
y = "Week-to-Week Distance Change (%)",
color = "Change Category"
) +
theme_minimal() +
theme(
legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggplotly(p)
})
# Monotony analysis
output$monotony_analysis <- renderPlotly({
monotony_data <- filtered_weekly_data() %>%
filter(!is.na(load_monotony), !is.na(training_strain))
if (nrow(monotony_data) > 0) {
p <- ggplot(monotony_data, aes(
x = load_monotony, y = training_strain,
color = player_id
)) +
geom_point(size = 3, alpha = 0.7) +
scale_color_viridis_d() +
labs(
title = "Load Monotony vs Training Strain",
subtitle = "Higher values may indicate overtraining risk",
x = "Load Monotony",
y = "Training Strain",
color = "Player"
) +
theme_minimal()
ggplotly(p)
} else {
ggplot() +
labs(title = "Insufficient data for monotony analysis") +
theme_minimal()
}
})
# Load planning table
output$load_planning_table <- renderDT({
planning_data <- load_recommendations()
if (nrow(planning_data) > 0) {
datatable(
planning_data,
options = list(
pageLength = 15,
scrollX = TRUE,
order = list(list(7, "desc")) # Sort by priority
),
colnames = c(
"Player", "Current Load", "Current ACWR", "Trend (%)",
"Recommended", "Target Min", "Target Max",
"Recommendation", "Priority"
)
) %>%
formatRound(columns = 2:7, digits = 0) %>%
formatRound(columns = c(3, 4), digits = 2) %>%
formatStyle(
"priority_level",
backgroundColor = styleEqual(
c("High", "Medium", "Low"),
c("#f8cecc", "#fff2cc", "#d5e8d4")
)
)
} else {
datatable(data.frame(Message = "No planning data available"))
}
})
# Planning value boxes
output$high_priority_players <- renderValueBox({
high_priority <- load_recommendations() %>%
filter(priority_level == "High") %>%
nrow()
valueBox(
value = high_priority,
subtitle = "High Priority Players",
icon = icon("exclamation"),
color = "red"
)
})
output$load_adjustments_needed <- renderValueBox({
adjustments <- load_recommendations() %>%
filter(priority_level %in% c("High", "Medium")) %>%
nrow()
valueBox(
value = adjustments,
subtitle = "Need Load Adjustments",
icon = icon("sliders-h"),
color = "yellow"
)
})
output$stable_progressions <- renderValueBox({
stable <- load_recommendations() %>%
filter(recommendation_type == "Maintain Current Progression") %>%
nrow()
valueBox(
value = stable,
subtitle = "Stable Progressions",
icon = icon("check"),
color = "green"
)
})
# Target achievement
output$target_achievement <- renderPlotly({
target_data <- filtered_weekly_data() %>%
filter(!is.na(week_start), !is.na(total_distance)) %>%
mutate(
week_start = as.Date(week_start),
target_distance = case_when(
season_phase == "Pre-Season" ~ 8000,
season_phase == "Early Season" ~ 7500,
season_phase == "Mid Season" ~ 7000,
season_phase == "End Season" ~ 6500,
TRUE ~ 7000
),
achievement_rate = (total_distance / target_distance) * 100,
target_category = case_when(
achievement_rate >= 90 & achievement_rate <= 110 ~ "On Target",
achievement_rate > 110 ~ "Above Target",
achievement_rate >= 80 ~ "Below Target",
TRUE ~ "Well Below Target"
)
)
if (nrow(target_data) == 0) {
p <- ggplot() +
labs(title = "No target achievement data available") +
theme_minimal()
return(ggplotly(p))
}
p <- ggplot(target_data, aes(x = week_start, y = achievement_rate, color = player_id)) +
geom_line(linewidth = 1, alpha = 0.7) +
geom_point(aes(shape = target_category), size = 2, alpha = 0.8) +
geom_hline(yintercept = c(90, 110), linetype = "dashed", alpha = 0.6) +
annotate("rect",
xmin = as.Date(-Inf, origin = "1970-01-01"),
xmax = as.Date(Inf, origin = "1970-01-01"),
ymin = 90, ymax = 110, alpha = 0.1, fill = "green"
) +
scale_color_viridis_d(option = "plasma") +
scale_x_date(date_labels = "%Y-%m-%d", date_breaks = "1 week") +
scale_shape_manual(
values = c(
"On Target" = 16, "Above Target" = 17, "Below Target" = 15,
"Well Below Target" = 4
)
) +
labs(
title = "Target Achievement Analysis",
subtitle = "Green band represents optimal target range (90-110%)",
x = "Week Starting",
y = "Target Achievement (%)",
color = "Player",
shape = "Achievement"
) +
theme_minimal() +
theme(
legend.position = "bottom",
axis.text.x = element_text(angle = 45, hjust = 1)
)
ggplotly(p, tooltip = c("x", "y", "colour", "shape"))
})
}
# Print instructions
cat("\n=== Weekly Load Periodisation Tracker ===\n")
cat("The weekly periodisation monitoring system is ready to launch.\n")
cat("This dashboard provides:\n")
cat("- Comprehensive weekly load aggregation and analysis\n")
cat("- Advanced ACWR monitoring with risk zone identification\n")
cat("- Seasonal periodisation effectiveness assessment\n")
cat("- Evidence-based load planning and recommendations\n")
cat("- Target achievement and progression tracking\n\n")
cat("To run the dashboard, execute: shinyApp(ui = ui, server = server)\n\n")
# Run the application
print("Launching Weekly Load Periodisation Tracker...")
shinyApp(ui = ui, server = server)
# Blog 5: Weekly Load Periodisation Tracking - Option B
# Clear environment
rm(list = ls())
# Load required libraries
library(shiny)
library(shinydashboard)
library(DT)
library(ggplot2)
library(plotly)
library(dplyr)
library(readr)
library(lubridate)
library(zoo)
library(tidyr)
library(viridis)
library(plotly)
library(purrr)
library(stringr) # Add missing stringr library
# =============================================================================
# DATA LOADING AND PREPROCESSING
# =============================================================================
# Read and preprocess the data
training_data <- read_csv("soccer_training_load_final_corrected.csv") %>%
mutate(
# Parse dates correctly (DD/MM/YYYY format)
date = dmy(Date),
player_id = `Player ID`,
session_type = `Session Day Type`,
md_relative = `MD Relative`,
duration = `Duration (min)`,
total_distance = `Distance (m)`,
distance_per_min = round(`Distance per min (m)`, 1),
hsr_distance = round(`High speed running (m) (19,8 – 25,2 km/h)`, 0),
sprint_distance = round(`Sprinting (m) (>25,2km/h)`, 0),
accelerations = `Accelerations (>3m/s2)`,
decelerations = `Decelerations (>3m/s2)`,
max_speed = `Max speed (km/h)`,
# Create composite load metrics
session_load = duration * distance_per_min,
high_intensity_distance = hsr_distance + sprint_distance,
# Training stress score (weighted by session intensity)
load_category = case_when(
distance_per_min > 120 ~ "Very High",
distance_per_min > 100 ~ "High",
distance_per_min > 80 ~ "Moderate",
distance_per_min > 60 ~ "Low",
TRUE ~ "Very Low"
),
training_stress = case_when(
load_category == "Very High" ~ session_load * 1.5,
load_category == "High" ~ session_load * 1.2,
load_category == "Moderate" ~ session_load * 1.0,
load_category == "Low" ~ session_load * 0.7,
load_category == "Very Low" ~ session_load * 0.5,
TRUE ~ session_load
),
# Weekly grouping variables
week_start = floor_date(date, "week", week_start = 1), # Monday start
week_number = isoweek(date),
year = year(date),
week_year = paste(year, sprintf("%02d", week_number), sep = "-W"),
# Season phase classification
season_phase = case_when(
month(date) %in% c(6, 7, 8) ~ "Pre-Season",
month(date) %in% c(9, 10, 11, 12) ~ "Early Season",
month(date) %in% c(1, 2, 3) ~ "Mid Season",
month(date) %in% c(4, 5) ~ "End Season",
TRUE ~ "Transition"
)
) %>%
filter(!is.na(date)) %>%
arrange(player_id, date)
# =============================================================================
# ANALYSIS FUNCTIONS
# =============================================================================
# Calculate comprehensive weekly metrics
calculate_weekly_metrics <- function(data) {
data %>%
group_by(player_id, week_start, week_year, season_phase) %>%
summarise(
# Session counts and composition
total_sessions = n(),
training_sessions = sum(session_type != "Match Day"),
match_sessions = sum(session_type == "Match Day"),
recovery_sessions = sum(session_type == "Recovery Training"),
physical_sessions = sum(session_type == "Physical Training"),
tactical_sessions = sum(session_type == "Tactical Training"),
# Volume metrics (absolute weekly totals)
total_duration = sum(duration, na.rm = TRUE),
total_distance = sum(total_distance, na.rm = TRUE),
total_hsr = sum(hsr_distance, na.rm = TRUE),
total_sprint = sum(sprint_distance, na.rm = TRUE),
total_accelerations = sum(accelerations, na.rm = TRUE),
total_decelerations = sum(decelerations, na.rm = TRUE),
# Intensity metrics (weekly averages)
avg_distance_per_min = mean(distance_per_min, na.rm = TRUE),
max_speed_week = max(max_speed, na.rm = TRUE),
total_session_load = sum(session_load, na.rm = TRUE),
total_training_stress = sum(training_stress, na.rm = TRUE),
# Derived weekly metrics
hsr_percentage = (total_hsr / total_distance) * 100,
sprint_percentage = (total_sprint / total_distance) * 100,
avg_session_duration = mean(duration, na.rm = TRUE),
# Weekly load distribution patterns
sessions_per_day = total_sessions / 7,
training_density = training_sessions / 7,
.groups = "drop"
) %>%
# Calculate rolling metrics and ratios
group_by(player_id) %>%
arrange(week_start) %>%
mutate(
# 4-week chronic load (rolling average)
chronic_distance = rollmean(total_distance, k = 4, fill = NA, align = "right"),
chronic_hsr = rollmean(total_hsr, k = 4, fill = NA, align = "right"),
chronic_session_load = rollmean(total_session_load, k = 4, fill = NA, align = "right"),
chronic_training_stress = rollmean(total_training_stress, k = 4, fill = NA, align = "right"),
# Acute:Chronic ratios (current week / 4-week average)
ac_distance_ratio = total_distance / chronic_distance,
ac_hsr_ratio = total_hsr / chronic_hsr,
ac_session_load_ratio = total_session_load / chronic_session_load,
ac_training_stress_ratio = total_training_stress / chronic_training_stress,
# Week-to-week percentage changes
distance_change = (total_distance - lag(total_distance)) / lag(total_distance) * 100,
hsr_change = (total_hsr - lag(total_hsr)) / lag(total_hsr) * 100,
session_load_change = (total_session_load - lag(total_session_load)) / lag(total_session_load) * 100,
# Load monotony and strain (Foster, 1998 adapted for weekly data)
load_monotony = total_session_load / (sd(c(
total_session_load,
lag(total_session_load, 1),
lag(total_session_load, 2)
), na.rm = TRUE) + 1),
training_strain = total_training_stress * load_monotony,
# ACWR risk categories
acwr_risk_category = case_when(
is.na(ac_session_load_ratio) ~ "Insufficient Data",
ac_session_load_ratio < 0.5 ~ "Very High Risk",
ac_session_load_ratio < 0.8 ~ "High Risk",
ac_session_load_ratio <= 1.3 ~ "Optimal Zone",
ac_session_load_ratio <= 1.5 ~ "High Risk",
TRUE ~ "Very High Risk"
)
) %>%
ungroup()
}
# Calculate seasonal periodisation effectiveness
calculate_phase_characteristics <- function(data) {
data %>%
group_by(season_phase) %>%
summarise(
# Basic characteristics
weeks_in_phase = n_distinct(week_start),
total_observations = n(),
avg_players_per_week = n() / n_distinct(week_start),
# Load characteristics
avg_weekly_distance = mean(total_distance, na.rm = TRUE),
avg_weekly_hsr = mean(total_hsr, na.rm = TRUE),
avg_weekly_sessions = mean(total_sessions, na.rm = TRUE),
avg_training_density = mean(training_density, na.rm = TRUE),
# Intensity characteristics
avg_session_intensity = mean(avg_distance_per_min, na.rm = TRUE),
avg_hsr_percentage = mean(hsr_percentage, na.rm = TRUE),
avg_max_speed = mean(max_speed_week, na.rm = TRUE),
# Load variability within phase
distance_cv = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
hsr_cv = sd(total_hsr, na.rm = TRUE) / mean(total_hsr, na.rm = TRUE) * 100,
# ACWR characteristics
avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
acwr_above_13 = sum(ac_session_load_ratio > 1.3, na.rm = TRUE),
acwr_below_08 = sum(ac_session_load_ratio < 0.8, na.rm = TRUE),
optimal_acwr_percentage = sum(ac_session_load_ratio >= 0.8 &
ac_session_load_ratio <= 1.3, na.rm = TRUE) /
sum(!is.na(ac_session_load_ratio)) * 100,
# Load progression characteristics
avg_week_to_week_change = mean(abs(distance_change), na.rm = TRUE),
progressive_weeks = sum(distance_change > 5, na.rm = TRUE),
maintenance_weeks = sum(abs(distance_change) <= 5, na.rm = TRUE),
reduction_weeks = sum(distance_change < -5, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
# Phase efficiency metrics
load_efficiency = avg_weekly_distance / avg_weekly_sessions,
intensity_balance = avg_hsr_percentage / (distance_cv + 1),
periodisation_quality = optimal_acwr_percentage / (distance_cv + 1),
effectiveness_rating = case_when(
periodisation_quality > 75 ~ "Excellent",
periodisation_quality > 50 ~ "Good",
periodisation_quality > 25 ~ "Moderate",
TRUE ~ "Needs Improvement"
)
)
}
# Generate load recommendations
generate_load_recommendations <- function(data) {
current_data <- data %>%
group_by(player_id) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
ungroup()
recommendations <- current_data %>%
mutate(
# Historical context
historical_avg = map_dbl(player_id, ~ {
player_history <- data %>% filter(player_id == .x)
mean(player_history$total_distance, na.rm = TRUE)
}),
# Recent trend (last 3 weeks)
recent_trend = map_dbl(player_id, ~ {
player_recent <- data %>%
filter(player_id == .x) %>%
arrange(desc(week_start)) %>%
slice_head(n = 3)
if (nrow(player_recent) >= 2) {
(player_recent$total_distance[1] - player_recent$total_distance[3]) /
player_recent$total_distance[3] * 100
} else {
0
}
}),
# Calculate recommended next week load
recommended_distance = case_when(
# High ACWR - reduce load
ac_session_load_ratio > 1.3 ~ pmax(total_distance * 0.8, historical_avg * 0.7),
# Low ACWR - increase load gradually
ac_session_load_ratio < 0.8 ~ pmin(total_distance * 1.2, historical_avg * 1.1),
# Optimal ACWR - maintain or slight progression
recent_trend < -10 ~ total_distance * 1.1, # If declining trend, increase
recent_trend > 20 ~ total_distance * 0.95, # If steep increase, slight reduction
TRUE ~ total_distance * 1.02 # Slight progression
),
# Generate specific recommendations
recommendation_type = case_when(
ac_session_load_ratio > 1.5 ~ "Load Reduction Required",
ac_session_load_ratio > 1.3 ~ "Moderate Load Reduction",
ac_session_load_ratio < 0.5 ~ "Gradual Load Increase",
ac_session_load_ratio < 0.8 ~ "Load Progression Needed",
abs(recent_trend) > 25 ~ "Stabilize Load Pattern",
TRUE ~ "Maintain Current Progression"
),
priority_level = case_when(
ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5 ~ "High",
ac_session_load_ratio > 1.3 | ac_session_load_ratio < 0.8 ~ "Medium",
abs(recent_trend) > 20 ~ "Medium",
TRUE ~ "Low"
),
# Calculate target ranges
target_min = recommended_distance * 0.9,
target_max = recommended_distance * 1.1
) %>%
select(
player_id, total_distance, ac_session_load_ratio, recent_trend,
recommended_distance, target_min, target_max,
recommendation_type, priority_level
)
return(recommendations)
}
# Pre-calculate analysis data
cat("Calculating weekly metrics...\n")
weekly_data <- calculate_weekly_metrics(training_data)
phase_characteristics <- calculate_phase_characteristics(weekly_data)
load_recommendations <- generate_load_recommendations(weekly_data)
cat("Weekly load analysis completed successfully!\n")
cat(
"Analysis completed for", length(unique(weekly_data$player_id)), "players across",
length(unique(weekly_data$week_start)), "weeks.\n"
)
# =============================================================================
# USER INTERFACE
# =============================================================================
ui <- dashboardPage(
dashboardHeader(title = "Weekly Load Periodisation Tracker"),
dashboardSidebar(
sidebarMenu(
menuItem("Weekly Load Overview", tabName = "weekly_overview", icon = icon("calendar-week")),
menuItem("ACWR Monitoring", tabName = "acwr_monitoring", icon = icon("balance-scale")),
menuItem("Periodisation Analysis", tabName = "periodisation", icon = icon("chart-line")),
menuItem("Load Planning", tabName = "load_planning", icon = icon("clipboard-list")),
menuItem("Progression Tracking", tabName = "progression", icon = icon("chart-line"))
),
# Filters
selectInput("selected_players_weekly", "Players:",
choices = sort(unique(training_data$player_id)),
selected = sort(unique(training_data$player_id))[1:8],
multiple = TRUE
),
dateRangeInput("date_range_weekly", "Date Range:",
start = min(weekly_data$week_start, na.rm = TRUE),
end = max(weekly_data$week_start, na.rm = TRUE)
),
selectInput("primary_metric_weekly", "Primary Metric:",
choices = c(
"total_distance", "total_hsr", "total_sprint",
"total_session_load", "total_training_stress"
),
selected = "total_distance"
),
numericInput("ac_ratio_window", "Chronic Window (weeks):",
value = 4, min = 2, max = 8
),
checkboxInput("show_team_avg", "Show Team Average", value = TRUE),
selectInput("season_phase_filter_weekly", "Season Phase:",
choices = c("All", "Pre-Season", "Early Season", "Mid Season", "End Season"),
selected = "All"
)
),
dashboardBody(
tabItems(
# Weekly Load Overview Tab
tabItem(
tabName = "weekly_overview",
fluidRow(
valueBoxOutput("total_weeks_tracked"),
valueBoxOutput("avg_weekly_distance"),
valueBoxOutput("current_week_sessions")
),
fluidRow(
box(
title = "Weekly Load Progression", status = "primary", solidHeader = TRUE,
width = 8, height = 450,
plotlyOutput("weekly_load_distribution")
),
box(
title = "Load Distribution", status = "info", solidHeader = TRUE,
width = 4, height = 450,
plotlyOutput("load_distribution_box")
)
),
fluidRow(
box(
title = "Weekly Summary Table", status = "success", solidHeader = TRUE,
width = 12,
DTOutput("weekly_summary_table")
)
)
),
# ACWR Monitoring Tab
tabItem(
tabName = "acwr_monitoring",
fluidRow(
valueBoxOutput("current_week_acwr_box"),
valueBoxOutput("optimal_acwr_players"),
valueBoxOutput("high_risk_acwr_players")
),
fluidRow(
box(
title = "Acute:Chronic Workload Ratio Monitoring", status = "primary", solidHeader = TRUE,
width = 8, height = 450,
plotlyOutput("ac_ratio_tracking")
),
box(
title = "ACWR Risk Distribution", status = "info", solidHeader = TRUE,
width = 4, height = 450,
plotlyOutput("acwr_risk_distribution")
)
),
fluidRow(
box(
title = "Current ACWR Status by Player", status = "warning", solidHeader = TRUE,
width = 6,
plotlyOutput("risk_status_overview")
),
box(
title = "Risk Alerts Table", status = "danger", solidHeader = TRUE,
width = 6,
DTOutput("risk_alerts")
)
)
),
# Periodisation Analysis Tab
tabItem(
tabName = "periodisation",
fluidRow(
box(
title = "Seasonal Phase Characteristics Comparison", status = "primary", solidHeader = TRUE,
width = 8, height = 450,
plotlyOutput("phase_comparison")
),
box(
title = "Phase Effectiveness", status = "info", solidHeader = TRUE,
width = 4, height = 450,
DTOutput("phase_effectiveness_table")
)
),
fluidRow(
box(
title = "Team Load Pattern Analysis", status = "success", solidHeader = TRUE,
width = 6,
plotlyOutput("team_pattern_analysis")
),
box(
title = "Load Distribution Analysis", status = "warning", solidHeader = TRUE,
width = 6,
plotlyOutput("load_distribution_analysis")
)
)
),
# Load Planning Tab
tabItem(
tabName = "load_planning",
fluidRow(
valueBoxOutput("players_needing_reduction"),
valueBoxOutput("players_needing_progression"),
valueBoxOutput("players_optimal_load")
),
fluidRow(
box(
title = "Load Planning Recommendations", status = "primary", solidHeader = TRUE,
width = 8,
DTOutput("load_planning_table")
),
box(
title = "Target Achievement Analysis", status = "info", solidHeader = TRUE,
width = 4, height = 450,
plotlyOutput("target_achievement")
)
)
),
# Progression Tracking Tab
tabItem(
tabName = "progression",
fluidRow(
box(
title = "Weekly Progression Quality Tracking", status = "primary", solidHeader = TRUE,
width = 8, height = 450,
plotlyOutput("progression_timeline")
),
box(
title = "Monotony vs Strain Analysis", status = "info", solidHeader = TRUE,
width = 4, height = 450,
plotlyOutput("monotony_analysis")
)
),
fluidRow(
box(
title = "Progression Summary", status = "success", solidHeader = TRUE,
width = 12,
DTOutput("progression_summary_table")
)
)
)
)
)
)
# =============================================================================
# SERVER LOGIC
# =============================================================================
server <- function(input, output, session) {
# Reactive data filtering
filtered_weekly_data <- reactive({
data <- weekly_data %>%
filter(
player_id %in% input$selected_players_weekly,
week_start >= input$date_range_weekly[1],
week_start <= input$date_range_weekly[2]
)
# Apply season phase filter if not "All"
if (input$season_phase_filter_weekly != "All") {
data <- data %>%
filter(season_phase == input$season_phase_filter_weekly)
}
data
})
# Value boxes
output$total_weeks_tracked <- renderValueBox({
valueBox(
value = length(unique(filtered_weekly_data()$week_start)),
subtitle = "Weeks Tracked",
icon = icon("calendar"),
color = "blue"
)
})
output$avg_weekly_distance <- renderValueBox({
avg_dist <- round(mean(filtered_weekly_data()$total_distance, na.rm = TRUE), 0)
valueBox(
value = paste0(format(avg_dist, big.mark = ","), "m"),
subtitle = "Average Weekly Distance",
icon = icon("route"),
color = "green"
)
})
output$current_week_sessions <- renderValueBox({
current_week_data <- filtered_weekly_data() %>%
filter(week_start == max(week_start, na.rm = TRUE))
avg_sessions <- round(mean(current_week_data$total_sessions, na.rm = TRUE), 1)
valueBox(
value = avg_sessions,
subtitle = "Avg Sessions This Week",
icon = icon("play"),
color = "yellow"
)
})
# Weekly Load Distribution
output$weekly_load_distribution <- renderPlotly({
plot_data <- filtered_weekly_data() %>%
arrange(week_start)
p <- ggplot(plot_data, aes(
x = week_start, y = get(input$primary_metric_weekly),
color = player_id
)) +
geom_line(linewidth = 1.2, alpha = 0.8) + # Changed size to linewidth
geom_point(size = 2.5, alpha = 0.9) +
scale_color_viridis_d(option = "plasma") +
labs(
title = paste("Weekly", str_to_title(gsub("_", " ", input$primary_metric_weekly)), "Progression"),
subtitle = paste(
"Tracking", length(input$selected_players_weekly), "players over",
length(unique(plot_data$week_start)), "weeks"
),
x = "Week Starting",
y = str_to_title(gsub("_", " ", input$primary_metric_weekly)),
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(size = 14, face = "bold"),
axis.text = element_text(size = 10)
)
# Add team average line if requested
if (input$show_team_avg) {
team_avg_data <- plot_data %>%
group_by(week_start) %>%
summarise(team_avg = mean(get(input$primary_metric_weekly), na.rm = TRUE), .groups = "drop")
p <- p + geom_line(
data = team_avg_data, aes(x = week_start, y = team_avg, color = NULL),
color = "black", linewidth = 2, linetype = "dashed", alpha = 0.8, # Changed size to linewidth
inherit.aes = FALSE
) +
annotate("text",
x = max(team_avg_data$week_start), y = max(team_avg_data$team_avg),
label = "Team Average", hjust = 1, vjust = -0.5, size = 3
)
}
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
})
# Load Distribution Box Plot
output$load_distribution_box <- renderPlotly({
p <- ggplot(filtered_weekly_data(), aes(x = season_phase, y = total_distance, fill = season_phase)) +
geom_boxplot(alpha = 0.7) +
scale_fill_viridis_d() +
labs(
title = "Weekly Load Distribution by Phase",
x = "Season Phase",
y = "Weekly Distance (m)"
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "none"
)
ggplotly(p)
})
# ACWR Tracking
output$ac_ratio_tracking <- renderPlotly({
ac_data <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio)) %>%
arrange(week_start) %>%
mutate(week_start = as.Date(week_start)) # Ensure proper Date format
p <- ggplot(ac_data, aes(x = week_start, y = ac_session_load_ratio, color = player_id)) +
geom_line(linewidth = 1.2, alpha = 0.8) +
geom_point(size = 2.5, alpha = 0.9) +
# Risk zone indicators
geom_hline(yintercept = 0.8, linetype = "dashed", color = "#2ecc71", alpha = 0.8, linewidth = 1) +
geom_hline(yintercept = 1.3, linetype = "dashed", color = "#2ecc71", alpha = 0.8, linewidth = 1) +
geom_hline(yintercept = 1.5, linetype = "dashed", color = "#e74c3c", alpha = 0.8, linewidth = 1) +
geom_hline(yintercept = 0.5, linetype = "dashed", color = "#e74c3c", alpha = 0.8, linewidth = 1) +
# Colored risk zones
annotate("rect",
xmin = as.Date(-Inf), xmax = as.Date(Inf), ymin = 0.8, ymax = 1.3,
fill = "#2ecc71", alpha = 0.1
) +
annotate("rect",
xmin = as.Date(-Inf), xmax = as.Date(Inf), ymin = 1.5, ymax = Inf,
fill = "#e74c3c", alpha = 0.15
) +
annotate("rect",
xmin = as.Date(-Inf), xmax = as.Date(Inf), ymin = -Inf, ymax = 0.5,
fill = "#e74c3c", alpha = 0.15
) +
# Zone labels
annotate("text",
x = max(ac_data$week_start), y = 1.05,
label = "OPTIMAL ZONE", hjust = 1, size = 3, fontface = "bold", color = "#27ae60"
) +
annotate("text",
x = max(ac_data$week_start), y = 1.7,
label = "HIGH RISK", hjust = 1, size = 3, fontface = "bold", color = "#c0392b"
) +
annotate("text",
x = max(ac_data$week_start), y = 0.3,
label = "HIGH RISK", hjust = 1, size = 3, fontface = "bold", color = "#c0392b"
) +
scale_color_viridis_d() +
scale_x_date(date_labels = "%Y-%m-%d") + # Explicit date scale
labs(
title = "Acute:Chronic Workload Ratio Monitoring",
subtitle = paste(
"Optimal range: 0.8-1.3 | High risk: >1.5 or <0.5 |",
input$ac_ratio_window, "week chronic window"
),
x = "Week Starting",
y = "AC Workload Ratio",
color = "Player"
) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(size = 14, face = "bold"),
plot.subtitle = element_text(size = 11, color = "gray60")
)
ggplotly(p, tooltip = c("x", "y", "colour")) %>%
layout(hovermode = "x unified")
})
# ACWR Value Boxes
output$current_week_acwr_box <- renderValueBox({
current_acwr_data <- filtered_weekly_data() %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
summarise(
avg_acwr = mean(ac_session_load_ratio, na.rm = TRUE),
high_risk_players = sum(ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5, na.rm = TRUE),
total_players = n()
)
avg_acwr <- round(current_acwr_data$avg_acwr, 2)
risk_players <- current_acwr_data$high_risk_players
# Dynamic coloring based on ACWR value and risk players
color <- if (avg_acwr < 0.8 || avg_acwr > 1.3) {
"red"
} else if (avg_acwr < 0.9 || avg_acwr > 1.2) {
"orange"
} else {
"green"
}
if (risk_players > 2) color <- "red"
icon_name <- if (avg_acwr >= 0.8 && avg_acwr <= 1.3 && risk_players <= 1) {
"check-circle"
} else if (avg_acwr > 1.3) {
"exclamation-triangle"
} else {
"arrow-down"
}
subtitle_text <- paste("Team Average ACWR (", risk_players, " high-risk players)")
valueBox(
value = avg_acwr,
subtitle = subtitle_text,
icon = icon(icon_name),
color = color
)
})
output$optimal_acwr_players <- renderValueBox({
current_optimal <- filtered_weekly_data() %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
summarise(optimal = sum(ac_session_load_ratio >= 0.8 & ac_session_load_ratio <= 1.3))
valueBox(
value = current_optimal$optimal,
subtitle = "Players in Optimal Zone",
icon = icon("thumbs-up"),
color = "green"
)
})
output$high_risk_acwr_players <- renderValueBox({
current_risk <- filtered_weekly_data() %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
summarise(high_risk = sum(ac_session_load_ratio > 1.5 | ac_session_load_ratio < 0.5))
valueBox(
value = current_risk$high_risk,
subtitle = "High Risk Players",
icon = icon("exclamation-triangle"),
color = "red"
)
})
# ACWR Risk Distribution
output$acwr_risk_distribution <- renderPlotly({
risk_data <- filtered_weekly_data() %>%
filter(!is.na(ac_session_load_ratio))
p <- ggplot(risk_data, aes(x = ac_session_load_ratio)) +
geom_histogram(bins = 30, fill = "#3498db", alpha = 0.7, color = "white") +
geom_vline(xintercept = c(0.8, 1.3), linetype = "dashed", color = "black", alpha = 0.6) +
labs(
title = "ACWR Distribution Across All Weekly Observations",
subtitle = "Distribution shows the frequency of different risk categories",
x = "Acute:Chronic Workload Ratio",
y = "Frequency"
) +
theme_minimal()
ggplotly(p)
})
# Risk Status Overview
output$risk_status_overview <- renderPlotly({
latest_data <- filtered_weekly_data() %>%
group_by(player_id) %>%
filter(week_start == max(week_start, na.rm = TRUE)) %>%
filter(!is.na(ac_session_load_ratio)) %>%
ungroup()
p <- latest_data %>%
ggplot(aes(
x = reorder(player_id, ac_session_load_ratio),
y = ac_session_load_ratio,
fill = acwr_risk_category
)) +
geom_col() +
geom_hline(yintercept = 0.8, color = "black", linetype = "dashed", alpha = 0.5) +
geom_hline(yintercept = 1.3, color = "black", linetype = "dashed", alpha = 0.5) +
geom_hline(yintercept = 1.5, color = "black", linetype = "solid", alpha = 0.7) +
scale_fill_manual(values = c(
"Optimal Zone" = "#2ecc71", "High Risk" = "#f39c12",
"Very High Risk" = "#e74c3c", "Insufficient Data" = "#95a5a6"
)) +
coord_flip() +
labs(
title = "Current ACWR Status by Player",
x = "Player ID",
y = "ACWR",
fill = "Risk Category"
) +
theme_minimal()
ggplotly(p)
})
# Risk Alerts
output$risk_alerts <- renderDT({
risk_alerts_data <- filtered_weekly_data() %>%
filter(acwr_risk_category %in% c("High Risk", "Very High Risk")) %>%
select(
player_id, week_start,
ac_session_load_ratio, acwr_risk_category
) %>%
arrange(desc(week_start)) %>%
head(20)
if (nrow(risk_alerts_data) > 0) {
datatable(
risk_alerts_data,
options = list(pageLength = 10, scrollX = TRUE),
colnames = c("Player", "Week", "ACWR", "Risk Category")
) %>%
formatRound(columns = 3, digits = 2) %>% # Fixed column index
formatStyle(
"acwr_risk_category",
backgroundColor = styleEqual(
c("High Risk", "Very High Risk"),
c("#f39c12", "#e74c3c")
),
color = "white"
)
} else {
# Create empty table with message
empty_data <- data.frame(
player_id = "No high-risk players",
week_start = as.Date(Sys.Date()), # Ensure proper date format
ac_session_load_ratio = NA,
acwr_risk_category = "Good"
)
datatable(empty_data, options = list(dom = "t"))
}
})
# Phase Comparison
output$phase_comparison <- renderPlotly({
phase_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(
season_phase, avg_weekly_distance, avg_weekly_hsr,
avg_acwr, optimal_acwr_percentage, distance_cv
) %>%
pivot_longer(-season_phase, names_to = "metric", values_to = "value") %>%
mutate(
metric = case_when(
metric == "avg_weekly_distance" ~ "Weekly Distance",
metric == "avg_weekly_hsr" ~ "Weekly HSR",
metric == "avg_acwr" ~ "Average ACWR",
metric == "optimal_acwr_percentage" ~ "Optimal ACWR %",
metric == "distance_cv" ~ "Distance CV%",
TRUE ~ metric
)
)
# Normalize values for comparison
phase_data <- phase_data %>%
group_by(metric) %>%
mutate(
value_normalized = (value - min(value, na.rm = TRUE)) /
(max(value, na.rm = TRUE) - min(value, na.rm = TRUE)) * 100
) %>%
ungroup()
p <- ggplot(phase_data, aes(x = metric, y = value_normalized, fill = season_phase)) +
geom_col(position = "dodge", alpha = 0.8) +
scale_fill_viridis_d(option = "plasma") +
labs(
title = "Seasonal Phase Characteristics Comparison",
subtitle = "Normalized values (0-100 scale) for key periodisation metrics",
x = "Metric",
y = "Normalized Value",
fill = "Season Phase"
) +
theme_minimal() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom"
)
ggplotly(p)
})
# Phase Effectiveness Table
output$phase_effectiveness_table <- renderDT({
effectiveness_data <- calculate_phase_characteristics(filtered_weekly_data()) %>%
select(
season_phase, weeks_in_phase, avg_weekly_distance, avg_weekly_hsr,
avg_acwr, optimal_acwr_percentage, distance_cv, periodisation_quality, effectiveness_rating
)
datatable(
effectiveness_data,
options = list(
pageLength = 10,
scrollX = TRUE,
dom = "t"
),
colnames = c(
"Phase", "Weeks", "Avg Distance", "Avg HSR",
"Avg ACWR", "Optimal ACWR %", "Distance CV%", "Quality Score", "Rating"
)
) %>%
formatRound(columns = 3:8, digits = 1) %>%
formatStyle(
"effectiveness_rating",
backgroundColor = styleEqual(
c("Excellent", "Good", "Moderate", "Needs Improvement"),
c("#d5e8d4", "#fff2cc", "#ffe6cc", "#f8cecc")
)
) %>%
formatStyle(
"optimal_acwr_percentage",
backgroundColor = styleInterval(c(60, 80), c("#ffcccc", "#ffffcc", "#ccffcc"))
)
})
# Load Planning Value Boxes
output$players_needing_reduction <- renderValueBox({
reduction_count <- load_recommendations %>%
filter(priority_level == "High", ac_session_load_ratio > 1.3) %>%
nrow()
valueBox(
value = reduction_count,
subtitle = "Need Load Reduction",
icon = icon("arrow-down"),
color = "red"
)
})
output$players_needing_progression <- renderValueBox({
progression_count <- load_recommendations %>%
filter(priority_level == "High", ac_session_load_ratio < 0.8) %>%
nrow()
valueBox(
value = progression_count,
subtitle = "Need Load Progression",
icon = icon("arrow-up"),
color = "blue"
)
})
output$players_optimal_load <- renderValueBox({
optimal_count <- load_recommendations %>%
filter(priority_level == "Low") %>%
nrow()
valueBox(
value = optimal_count,
subtitle = "Optimal Load",
icon = icon("check"),
color = "green"
)
})
# Load Planning Table
output$load_planning_table <- renderDT({
planning_data <- load_recommendations %>%
filter(player_id %in% input$selected_players_weekly)
datatable(
planning_data,
options = list(
pageLength = 15,
scrollX = TRUE,
order = list(list(8, "desc")) # Sort by priority
),
colnames = c(
"Player", "Current Load", "Current ACWR", "Recent Trend (%)",
"Recommended Load", "Target Min", "Target Max",
"Recommendation", "Priority"
)
) %>%
formatRound(columns = 2:7, digits = 0) %>%
formatRound(columns = c(3, 4), digits = 2) %>%
formatStyle(
"priority_level",
backgroundColor = styleEqual(
c("High", "Medium", "Low"),
c("#f8cecc", "#fff2cc", "#d5e8d4")
)
) %>%
formatStyle(
"ac_session_load_ratio",
backgroundColor = styleInterval(c(0.8, 1.3), c("#ffcccc", "#ccffcc", "#ffcccc"))
) %>%
formatStyle(
"recent_trend",
backgroundColor = styleInterval(c(-10, 10), c("#cce5ff", "#ffffff", "#ffcccc"))
)
})
# Target Achievement
output$target_achievement <- renderPlotly({
target_data <- filtered_weekly_data() %>%
mutate(
target_distance = case_when(
season_phase == "Pre-Season" ~ 8000,
season_phase == "Early Season" ~ 7500,
season_phase == "Mid Season" ~ 7000,
season_phase == "End Season" ~ 6500,
TRUE ~ 7000
),
achievement_rate = (total_distance / target_distance) * 100,
target_category = case_when(
achievement_rate >= 90 & achievement_rate <= 110 ~ "On Target",
achievement_rate > 110 ~ "Above Target",
achievement_rate >= 80 ~ "Below Target",
TRUE ~ "Well Below Target"
)
)
p <- ggplot(target_data, aes(x = week_start, y = achievement_rate, color = player_id)) +
geom_line(linewidth = 1, alpha = 0.7) + # Changed size to linewidth
geom_point(aes(shape = target_category), size = 2, alpha = 0.8) +
geom_hline(yintercept = c(90, 110), linetype = "dashed", alpha = 0.6) +
geom_ribbon(aes(ymin = 90, ymax = 110, group = 1), alpha = 0.1, fill = "green") +
scale_color_viridis_d(option = "plasma") +
scale_shape_manual(
values = c("On Target" = 16, "Above Target" = 17, "Below Target" = 15, "Well Below Target" = 4)
) +
labs(
title = "Target Achievement Analysis",
subtitle = "Green band represents optimal target achievement (90-110%)",
x = "Week Starting",
y = "Target Achievement (%)",
color = "Player",
shape = "Achievement Category"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p, tooltip = c("x", "y", "colour", "shape"))
})
# Weekly Summary Table
output$weekly_summary_table <- renderDT({
summary_data <- filtered_weekly_data() %>%
arrange(desc(week_start)) %>%
head(50) %>%
select(
player_id, week_start, total_sessions, total_distance, total_hsr,
ac_session_load_ratio, acwr_risk_category, season_phase
)
datatable(
summary_data,
options = list(
pageLength = 15,
scrollX = TRUE,
order = list(list(1, "desc"))
),
colnames = c("Player", "Week", "Sessions", "Distance", "HSR", "ACWR", "Risk Category", "Phase")
) %>%
formatRound(columns = c("total_distance", "total_hsr"), digits = 0) %>%
formatRound(columns = "ac_session_load_ratio", digits = 2) %>%
formatStyle(
"acwr_risk_category",
backgroundColor = styleEqual(
c("Optimal Zone", "High Risk", "Very High Risk", "Insufficient Data"),
c("#d5e8d4", "#fff2cc", "#f8cecc", "#e6e6e6")
)
)
})
# Progression Timeline
output$progression_timeline <- renderPlotly({
p <- ggplot(filtered_weekly_data(), aes(x = week_start, y = total_distance, color = player_id)) +
geom_line(linewidth = 1.2, alpha = 0.8) + # Changed size to linewidth
geom_smooth(method = "loess", se = FALSE, alpha = 0.6) +
scale_color_viridis_d(option = "plasma") +
labs(
title = "Weekly Progression Quality Tracking",
subtitle = "Trend lines show progression patterns over time",
x = "Week Starting",
y = "Weekly Distance (m)",
color = "Player"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p, tooltip = c("x", "y", "colour"))
})
# Monotony Analysis
output$monotony_analysis <- renderPlotly({
monotony_data <- filtered_weekly_data() %>%
filter(!is.na(load_monotony), !is.na(training_strain))
p <- ggplot(monotony_data, aes(x = load_monotony, y = training_strain, color = player_id)) +
geom_point(size = 3, alpha = 0.7) +
scale_color_viridis_d(option = "plasma") +
labs(
title = "Training Monotony vs Strain Analysis",
subtitle = "Higher values indicate potential overtraining risk",
x = "Training Monotony",
y = "Training Strain",
color = "Player"
) +
theme_minimal()
ggplotly(p)
})
# Progression Summary Table
output$progression_summary_table <- renderDT({
progression_summary <- filtered_weekly_data() %>%
group_by(player_id) %>%
summarise(
weeks_tracked = n(),
avg_distance = round(mean(total_distance, na.rm = TRUE), 0),
distance_trend = round(cor(as.numeric(week_start), total_distance, use = "complete.obs") * 1000, 1),
avg_acwr = round(mean(ac_session_load_ratio, na.rm = TRUE), 2),
optimal_weeks = sum(acwr_risk_category == "Optimal Zone", na.rm = TRUE),
risk_weeks = sum(acwr_risk_category %in% c("High Risk", "Very High Risk"), na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
trend_direction = case_when(
distance_trend > 10 ~ "Increasing",
distance_trend < -10 ~ "Decreasing",
TRUE ~ "Stable"
)
) %>%
arrange(desc(avg_distance))
datatable(
progression_summary,
options = list(
pageLength = 15,
scrollX = TRUE
),
colnames = c(
"Player", "Weeks", "Avg Distance", "Trend Score", "Avg ACWR",
"Optimal Weeks", "Risk Weeks", "Trend Direction"
)
) %>%
formatRound(columns = c("avg_distance", "distance_trend", "avg_acwr"), digits = 1) %>%
formatStyle(
"trend_direction",
backgroundColor = styleEqual(
c("Increasing", "Stable", "Decreasing"),
c("#d5e8d4", "#fff2cc", "#f8cecc")
)
) %>%
formatStyle(
"avg_acwr",
backgroundColor = styleInterval(c(0.8, 1.3), c("#ffcccc", "#ccffcc", "#ffcccc"))
)
})
# Team Pattern Analysis
output$team_pattern_analysis <- renderPlotly({
team_data <- filtered_weekly_data() %>%
group_by(week_start) %>%
summarise(
team_avg_distance = mean(total_distance, na.rm = TRUE),
team_distance_cv = sd(total_distance, na.rm = TRUE) / mean(total_distance, na.rm = TRUE) * 100,
high_risk_players = sum(acwr_risk_category %in% c("High Risk", "Very High Risk"), na.rm = TRUE),
.groups = "drop"
)
p <- ggplot(team_data, aes(x = week_start)) +
geom_line(aes(y = team_avg_distance, color = "Team Average Distance"), linewidth = 1.2) + # Changed size to linewidth
geom_line(aes(y = high_risk_players * 1000, color = "High Risk Players (x1000)"), linewidth = 1.2) + # Changed size to linewidth
scale_color_manual(values = c("Team Average Distance" = "#3498db", "High Risk Players (x1000)" = "#e74c3c")) +
labs(
title = "Team Load Progression with Risk Profile",
x = "Week", y = "Values", color = "Metric"
) +
theme_minimal() +
theme(legend.position = "bottom")
ggplotly(p)
})
# Load Distribution Analysis
output$load_distribution_analysis <- renderPlotly({
distribution_data <- filtered_weekly_data() %>%
group_by(week_start) %>%
summarise(
load_range = max(total_distance, na.rm = TRUE) - min(total_distance, na.rm = TRUE),
players_above_avg = sum(total_distance > mean(total_distance, na.rm = TRUE)),
players_below_avg = n() - players_above_avg,
.groups = "drop"
)
p <- ggplot(distribution_data, aes(x = week_start)) +
geom_line(aes(y = load_range / 100, color = "Load Range (/100)"), linewidth = 1.2) + # Changed size to linewidth
geom_col(aes(y = players_above_avg), alpha = 0.3, fill = "blue") +
scale_color_manual(values = c("Load Range (/100)" = "#e74c3c")) +
labs(
title = "Team Load Distribution Analysis",
subtitle = "Bars show players above average; Line shows load range",
x = "Week Starting",
y = "Scaled Values",
color = "Metric"
) +
theme_minimal()
ggplotly(p)
})
}
# =============================================================================
# RUN THE APPLICATION
# =============================================================================
# Run the application
cat("Starting Weekly Load Periodisation Tracker...\n")
cat("The dashboard will open in your default web browser.\n")
cat("Close this R session or press Ctrl+C to stop the dashboard.\n\n")
shinyApp(ui = ui, server = server)



