diff --git a/assets/print-calendar.css b/assets/print-calendar.css
new file mode 100644
index 0000000..6dc7fb4
--- /dev/null
+++ b/assets/print-calendar.css
@@ -0,0 +1,474 @@
+@import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:opsz,wdth,wght@6..12,75..125,600..900&family=Open+Sans:wght@400..800&display=swap");
+
+:root {
+ --pc-font-body: "Open Sans", Arial, sans-serif;
+ --pc-font-display: "Nunito Sans", "Open Sans", Arial, sans-serif;
+ --pc-page-padding: 0.45in;
+ --pc-gap: 8px;
+ --pc-border: #d5dde6;
+ --pc-preview-bg: #d9dce3;
+ --pc-team-logo-size: clamp(40px, 5vw, 56px);
+ --pc-brand-logo-height: clamp(40px, 5vw, 56px);
+ --pc-brand-logo-max-width: 34%;
+ --pc-muted-day-bg: rgba(243, 245, 248, 0.72);
+ --pc-empty-day-bg: rgba(233, 237, 242, 0.72);
+ --pc-event-logo-height: clamp(28px, 100%, 74px);
+ --pc-qr-size: 50px;
+ --pc-qr-offset: calc(var(--pc-qr-size) + var(--pc-gap));
+}
+
+body {
+ margin: 0;
+ padding: 20px;
+ font-family: var(--pc-font-body);
+ color: #111;
+ background: #fff;
+}
+
+.print-shell {
+ margin: 0;
+ width: calc(100% / var(--sheet-scale));
+ background: #fff;
+ transform-origin: top left;
+ transform: scale(var(--sheet-scale));
+}
+
+.print-page {
+ padding: var(--pc-page-padding);
+}
+
+@media screen {
+ body.print-preview {
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 24px;
+ background: var(--pc-preview-bg);
+ }
+
+ body.print-preview .print-shell {
+ box-shadow: 0 10px 30px rgba(17, 24, 39, 0.2);
+ }
+
+ body.print-preview .print-shell.letter {
+ width: 8.5in;
+ min-height: 11in;
+ }
+
+ body.print-preview .print-shell.ledger {
+ width: 11in;
+ min-height: 17in;
+ }
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin: 0 0 12px;
+ padding-bottom: 8px;
+ border-bottom: 2px solid var(--team-accent, #b61f0f);
+}
+
+.header-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.team-logo,
+.league-logo {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.team-logo {
+ width: var(--pc-team-logo-size);
+ height: var(--pc-team-logo-size);
+}
+
+.team-logo img,
+.team-logo-img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: contain;
+}
+
+.league-logo {
+ height: var(--pc-brand-logo-height);
+ max-width: var(--pc-brand-logo-max-width);
+}
+
+.league-logo a,
+.league-logo .custom-logo-link,
+.league-logo .wp-block-site-logo__link {
+ height: 100%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.league-logo img,
+.league-logo-img,
+.league-logo .custom-logo,
+.league-logo .wp-block-site-logo__image {
+ width: auto;
+ height: 100%;
+ max-width: 100%;
+ display: block;
+ object-fit: contain;
+}
+
+.header-copy {
+ min-width: 0;
+}
+
+.title {
+ margin: 0;
+ font-family: var(--pc-font-display);
+ font-size: 28px;
+ font-weight: 800;
+ font-variation-settings: "wdth" 92, "wght" 800;
+ line-height: 1.1;
+ color: var(--team-ink, #111);
+}
+
+.meta {
+ margin: 0;
+ font-size: 13px;
+ color: var(--team-muted-ink, #333);
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--pc-gap);
+ margin: 0;
+}
+
+.legend-item {
+ display: inline-flex;
+ align-items: center;
+ font-family: var(--pc-font-display);
+ font-variation-settings: "wdth" 70, "wght" 700;
+ background: #fff;
+}
+
+.sheet-grid {
+ display: grid;
+ grid-template-columns: repeat(var(--month-columns), minmax(0, 1fr));
+ gap: var(--pc-gap);
+ align-items: start;
+}
+
+.sheet-grid > .month:nth-child(3):last-child {
+ grid-column: 1 / -1;
+ width: calc((100% - var(--pc-gap)) / 2);
+ max-width: calc((100% - var(--pc-gap)) / 2);
+ justify-self: center;
+}
+
+.month {
+ margin: 0;
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+.month-title {
+ margin: 0;
+ padding: 2px;
+ font-family: var(--pc-font-display);
+ font-size: calc(20px * var(--month-font-scale));
+ font-weight: 800;
+ font-variation-settings: "wdth" 80, "wght" 800;
+ line-height: 1.1;
+ letter-spacing: 0.01em;
+ text-align: center;
+ text-transform: uppercase;
+ background: var(--team-primary);
+ color: var(--team-on-primary);
+}
+
+.dow,
+.grid {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.dow span {
+ display: block;
+ padding: 2px 1px;
+ font-size: calc(10px * var(--month-font-scale));
+ font-weight: 700;
+ text-align: center;
+ text-transform: uppercase;
+ border-bottom: 1px solid var(--pc-border);
+ background: var(--team-link-color);
+ color: var(--team-on-link-color);
+}
+
+.day {
+ --corner-badge-size: calc(15px * var(--month-font-scale));
+ --corner-badge-offset: 0px;
+ position: relative;
+ overflow: hidden;
+ aspect-ratio: var(--day-aspect);
+ background: #fff;
+}
+
+.day.muted {
+ background: var(--pc-muted-day-bg);
+ color: #9aa5b1;
+}
+
+.day.no-events {
+ background: var(--pc-empty-day-bg);
+}
+
+.day-num {
+ position: absolute;
+ top: var(--corner-badge-offset);
+ left: var(--corner-badge-offset);
+ z-index: 4;
+ width: var(--corner-badge-size);
+ height: var(--corner-badge-size);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--pc-font-display);
+ font-size: calc(12px * var(--month-font-scale));
+ font-weight: 800;
+ font-variation-settings: "wdth" 86, "wght" 800;
+ line-height: 1;
+}
+
+.day.has-events .day-num {
+ color: var(--day-num-color, #fff);
+}
+
+.events-stack {
+ height: 100%;
+ display: grid;
+ grid-template-rows: repeat(var(--event-count), minmax(0, 1fr));
+}
+
+.event {
+ --event-top-band: calc(var(--corner-badge-size, 11px) + var(--corner-badge-offset, 2px));
+ --event-bottom-band: 14px;
+ --event-logo-height: var(--pc-event-logo-height);
+ position: relative;
+ background: var(--event-bg, var(--team-primary, #1b76d1));
+ color: var(--event-fg, #fff);
+}
+
+.event.h {
+ --event-bg: var(--team-primary, #1b76d1);
+}
+
+.event.a {
+ --event-bg: var(--team-link-color, var(--team-secondary, #8b3f1f));
+}
+
+.event-center {
+ position: absolute;
+ top: var(--event-top-band);
+ left: 0;
+ right: 0;
+ bottom: var(--event-bottom-band);
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ width: 100%;
+ font-size: calc(10px * var(--month-font-scale));
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+}
+
+.event-center img {
+ width: auto;
+ height: var(--event-logo-height);
+ max-width: 100%;
+ max-height: 100%;
+ display: block;
+ object-fit: contain;
+ object-position: center;
+}
+
+.event-name {
+ width: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ overflow: hidden;
+ text-overflow: clip;
+ white-space: normal;
+ word-break: normal;
+ overflow-wrap: normal;
+ hyphens: none;
+ line-height: 1.05;
+ font-weight: 700;
+ opacity: 0.85;
+}
+
+.event.no-logo .event-name {
+ font-family: var(--pc-font-display);
+ font-weight: 700;
+ font-variation-settings: "wdth" 30, "wght" 700;
+ letter-spacing: -0.01em;
+ text-transform: uppercase;
+}
+
+.ha-flag {
+ position: absolute;
+ top: var(--corner-badge-offset, 2px);
+ right: var(--corner-badge-offset, 2px);
+ z-index: 3;
+ width: var(--corner-badge-size, 11px);
+ height: var(--corner-badge-size, 11px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--pc-font-display);
+ font-size: calc(10px * var(--month-font-scale));
+ font-weight: 900;
+ font-variation-settings: "wdth" 84, "wght" 900;
+ line-height: 1;
+ letter-spacing: -0.01em;
+ background: #111;
+ color: #fff;
+}
+
+.event.a .ha-flag {
+ background: #fff;
+ color: #111;
+}
+
+.event-time {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 2px;
+ z-index: 3;
+ font-size: calc(12px * var(--month-font-scale));
+ font-weight: 800;
+ line-height: 1;
+ text-transform: uppercase;
+ text-align: center;
+ color: currentColor;
+ opacity: 0.95;
+}
+
+.empty {
+ padding: 16px;
+ border: 2px dashed #c8d2de;
+ background: #f8fafc;
+}
+
+.footer-meta {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--pc-gap);
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid var(--pc-border);
+}
+
+.legend-bottom {
+ --month-width: calc((100% - ((var(--month-columns) - 1) * var(--pc-gap))) / var(--month-columns));
+ flex: 0 0 var(--month-width);
+ width: var(--month-width);
+ max-width: var(--month-width);
+ align-content: flex-start;
+ justify-content: center;
+ gap: 6px;
+}
+
+.legend-bottom .legend-item {
+ padding: 3px 6px;
+ font-size: 10px;
+ text-align: center;
+}
+
+.footer-qr {
+ position: relative;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-end;
+ gap: var(--pc-gap);
+ min-height: var(--pc-qr-size);
+ padding-right: var(--pc-qr-offset);
+}
+
+.footer-qr-copy {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ text-align: right;
+}
+
+.footer-qr-label {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--team-muted-ink, #333);
+}
+
+.footer-qr-link {
+ font-size: 10px;
+ font-weight: 700;
+ text-decoration: none;
+ color: var(--team-ink, #111);
+}
+
+.footer-qr-image {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: var(--pc-qr-size);
+ height: var(--pc-qr-size);
+ display: block;
+ border: 1px solid var(--pc-border);
+ background: #fff;
+}
+
+@media print {
+ body,
+ body.print-preview {
+ padding: 0;
+ display: block;
+ background: #fff;
+ }
+
+ .print-shell,
+ body.print-preview .print-shell,
+ body.print-preview .print-shell.letter,
+ body.print-preview .print-shell.ledger {
+ width: auto;
+ min-height: auto;
+ box-shadow: none;
+ }
+
+ .print-page {
+ padding: 0;
+ }
+
+ .header {
+ margin-bottom: 8px;
+ }
+
+ .title {
+ font-size: calc(26px * var(--month-font-scale));
+ }
+}
diff --git a/includes/sp-printable-calendars.php b/includes/sp-printable-calendars.php
new file mode 100644
index 0000000..0d5d781
--- /dev/null
+++ b/includes/sp-printable-calendars.php
@@ -0,0 +1,1521 @@
+booted ) {
+ return;
+ }
+
+ $this->booted = true;
+
+ if ( ! $this->sportspress_available() ) {
+ if ( is_admin() ) {
+ add_action( 'admin_notices', array( $this, 'render_missing_dependency_notice' ) );
+ }
+ return;
+ }
+
+ add_filter( 'query_vars', array( $this, 'register_query_vars' ) );
+ add_action( 'template_redirect', array( $this, 'maybe_render' ) );
+ add_action( 'sportspress_after_single_team', array( $this, 'render_team_page_cta' ) );
+
+ if ( is_admin() ) {
+ add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
+ add_action( 'admin_init', array( $this, 'register_settings' ) );
+ add_filter( 'option_page_capability_' . self::OPTION_GROUP, array( $this, 'settings_capability' ) );
+ }
+ }
+
+ /**
+ * Show dependency notice.
+ */
+ public function render_missing_dependency_notice() {
+ if ( ! current_user_can( 'activate_plugins' ) ) {
+ return;
+ }
+
+ echo '
';
+ echo esc_html__( 'Printable calendars require SportsPress to be installed and active.', 'tonys-sportspress-enhancements' );
+ echo '
';
+ }
+
+ /**
+ * Register query vars for the printable route.
+ *
+ * @param array $vars Existing vars.
+ * @return array
+ */
+ public function register_query_vars( $vars ) {
+ $vars[] = self::QUERY_FLAG;
+ $vars[] = 'sp_team';
+ $vars[] = 'sp_season';
+ $vars[] = 'paper';
+
+ return $vars;
+ }
+
+ /**
+ * Render team CTA.
+ */
+ public function render_team_page_cta() {
+ $team_id = get_the_ID();
+ if ( ! is_int( $team_id ) || $team_id <= 0 || 'sp_team' !== get_post_type( $team_id ) ) {
+ return;
+ }
+
+ $season_id = absint( (string) get_option( 'sportspress_season', '0' ) );
+ $link = $this->build_url( $team_id, $season_id, 'letter' );
+
+ echo '';
+ echo '';
+ echo esc_html__( 'Printable Schedule', 'tonys-sportspress-enhancements' );
+ echo '';
+ echo '
';
+ }
+
+ /**
+ * Register plugin settings.
+ */
+ public function register_settings() {
+ register_setting(
+ self::OPTION_GROUP,
+ self::OPTION_KEY,
+ array(
+ 'type' => 'array',
+ 'sanitize_callback' => array( $this, 'sanitize_settings' ),
+ 'default' => $this->default_settings(),
+ )
+ );
+ }
+
+ /**
+ * Add settings page.
+ */
+ public function add_settings_page() {
+ add_submenu_page(
+ 'sportspress',
+ __( 'Tony\'s Settings', 'tonys-sportspress-enhancements' ),
+ __( 'Tony\'s Settings', 'tonys-sportspress-enhancements' ),
+ 'manage_sportspress',
+ self::PAGE_SLUG,
+ array( $this, 'render_settings_page' )
+ );
+ }
+
+ /**
+ * Capability required to save this settings group.
+ *
+ * @return string
+ */
+ public function settings_capability() {
+ return 'manage_sportspress';
+ }
+
+ /**
+ * Sanitize settings.
+ *
+ * @param mixed $input Raw input.
+ * @return array
+ */
+ public function sanitize_settings( $input ) {
+ $existing = wp_parse_args(
+ get_option( self::OPTION_KEY, array() ),
+ $this->default_settings()
+ );
+
+ $current = wp_parse_args(
+ is_array( $input ) ? $input : array(),
+ $existing
+ );
+
+ return array(
+ 'calendar_feed_url' => isset( $existing['calendar_feed_url'] ) && is_string( $existing['calendar_feed_url'] ) ? $existing['calendar_feed_url'] : '',
+ 'sync_interval_minutes' => isset( $existing['sync_interval_minutes'] ) ? absint( (string) $existing['sync_interval_minutes'] ) : 60,
+ 'venue_color_overrides' => $this->sanitize_venue_color_overrides( isset( $current['venue_color_overrides'] ) ? $current['venue_color_overrides'] : array() ),
+ 'venue_use_team_primary' => $this->sanitize_venue_primary_flags( isset( $current['venue_use_team_primary'] ) ? $current['venue_use_team_primary'] : array() ),
+ );
+ }
+
+ /**
+ * Render settings page.
+ */
+ public function render_settings_page() {
+ if ( ! current_user_can( 'manage_sportspress' ) ) {
+ return;
+ }
+
+ $current_tab = $this->current_settings_tab();
+ $season_id = $this->selected_season_id();
+ $seasons = $this->get_seasons();
+ $venues = $this->get_venues_for_season( $season_id );
+ $overrides = $this->get_venue_color_overrides();
+ $primary_flags = $this->get_venue_primary_flags();
+ $season_key = (string) $season_id;
+ $season_overrides = isset( $overrides[ $season_key ] ) && is_array( $overrides[ $season_key ] ) ? $overrides[ $season_key ] : array();
+ $season_primary_flags = isset( $primary_flags[ $season_key ] ) && is_array( $primary_flags[ $season_key ] ) ? $primary_flags[ $season_key ] : array();
+
+ echo '';
+ echo '
' . esc_html__( 'Tony\'s Settings', 'tonys-sportspress-enhancements' ) . '
';
+ $this->render_settings_tabs( $current_tab );
+ echo '
';
+ echo '
';
+ }
+
+ /**
+ * Render Tony's settings tabs.
+ *
+ * @param string $current_tab Current tab key.
+ */
+ private function render_settings_tabs( $current_tab ) {
+ $tabs = array(
+ self::TAB_PRINTABLE => __( 'Printable Calendars', 'tonys-sportspress-enhancements' ),
+ );
+
+ echo '';
+ }
+
+ /**
+ * Resolve the current settings tab.
+ *
+ * @return string
+ */
+ private function current_settings_tab() {
+ $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : self::TAB_PRINTABLE;
+
+ return self::TAB_PRINTABLE === $tab ? $tab : self::TAB_PRINTABLE;
+ }
+
+ /**
+ * Render the printable page when the flag is present.
+ */
+ public function maybe_render() {
+ if ( '1' !== (string) get_query_var( self::QUERY_FLAG ) ) {
+ return;
+ }
+
+ $team_id = absint( (string) get_query_var( 'sp_team' ) );
+ if ( $team_id <= 0 || 'sp_team' !== get_post_type( $team_id ) ) {
+ status_header( 400 );
+ nocache_headers();
+ echo esc_html__( 'Missing or invalid team id.', 'tonys-sportspress-enhancements' );
+ exit;
+ }
+
+ $season_id = absint( (string) get_query_var( 'sp_season' ) );
+ if ( $season_id <= 0 ) {
+ $season_id = absint( (string) get_option( 'sportspress_season', '0' ) );
+ }
+
+ $paper = strtolower( (string) get_query_var( 'paper' ) );
+ if ( ! in_array( $paper, $this->allowed_paper_sizes, true ) ) {
+ $paper = 'letter';
+ }
+
+ $team_name = get_the_title( $team_id );
+ $team_logo = get_the_post_thumbnail( $team_id, array( 72, 72 ), array( 'class' => 'team-logo-img' ) );
+ $brand_logo = $this->get_header_brand_logo();
+ $site_url = home_url( '/' );
+ $qr_url = 'https://api.qrserver.com/v1/create-qr-code/?size=144x144&data=' . rawurlencode( $site_url );
+ $season_name = '';
+ $entries = $this->get_schedule_entries( $team_id, $season_id );
+ $team_palette = $this->get_team_color_palette( $team_id );
+ $team_primary_for_fields = $this->get_strict_team_primary_color( $team_id );
+ $entries_by_day = array();
+ $venue_colors = array();
+ $month_keys = array();
+ $layout = array();
+
+ if ( $season_id > 0 ) {
+ $season = get_term( $season_id, 'sp_season' );
+ if ( $season && ! is_wp_error( $season ) ) {
+ $season_name = $season->name;
+ }
+ }
+
+ foreach ( $entries as $entry ) {
+ $entries_by_day[ $entry['day_key'] ][] = $entry;
+
+ if ( '' !== $entry['venue_name'] && is_string( $entry['venue_key'] ) && ! isset( $venue_colors[ $entry['venue_key'] ] ) ) {
+ $venue_colors[ $entry['venue_key'] ] = array(
+ 'name' => (string) $entry['venue_name'],
+ 'color' => $this->get_venue_color( (string) $entry['venue_name'], $season_id, (int) $entry['venue_id'], $team_primary_for_fields ),
+ );
+ }
+ }
+
+ $month_keys = $this->get_month_keys( $entries );
+ $layout = $this->get_sheet_layout( count( $month_keys ), $paper );
+
+ status_header( 200 );
+ nocache_headers();
+ header( 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' ) );
+
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '' . esc_html( $team_name . ' Printable Schedule' ) . '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+
+ $root_vars = array(
+ '--sheet-scale:' . $layout['sheet_scale'],
+ '--month-columns:' . $layout['columns'],
+ '--month-font-scale:' . $layout['font_scale'],
+ '--day-aspect:' . $layout['day_aspect'],
+ '--team-primary:' . $team_palette['primary'],
+ '--team-on-primary:' . $this->get_readable_text_color( $team_palette['primary'] ),
+ '--team-primary-contrast:' . $this->get_readable_text_color( $team_palette['primary'] ),
+ '--team-link-color:' . $team_palette['secondary'],
+ '--team-on-link-color:' . $this->get_readable_text_color( $team_palette['secondary'] ),
+ '--team-link-color-contrast:' . $this->get_readable_text_color( $team_palette['secondary'] ),
+ '--team-secondary:' . $team_palette['secondary'],
+ '--team-on-secondary:' . $this->get_readable_text_color( $team_palette['secondary'] ),
+ '--team-secondary-contrast:' . $this->get_readable_text_color( $team_palette['secondary'] ),
+ '--team-accent:' . $team_palette['accent'],
+ '--team-ink:' . $team_palette['ink'],
+ '--team-muted-ink:' . $team_palette['muted_ink'],
+ );
+
+ echo '';
+ echo '';
+ echo '';
+
+ if ( empty( $entries ) ) {
+ echo '
';
+ echo '' . esc_html__( 'No SportsPress events were found for this team and season.', 'tonys-sportspress-enhancements' ) . '
';
+ echo '';
+ } else {
+ echo '
';
+ foreach ( $month_keys as $month_key ) {
+ $this->render_month_grid( $month_key, $entries_by_day, $venue_colors, $team_palette );
+ }
+ echo '';
+ }
+
+ echo '';
+ echo '
';
+ echo '';
+ echo '';
+ exit;
+ }
+
+ /**
+ * Build the printable route URL.
+ *
+ * @param int $team_id Team ID.
+ * @param int $season_id Season ID.
+ * @param string $paper Paper size.
+ * @return string
+ */
+ private function build_url( $team_id, $season_id, $paper ) {
+ return add_query_arg(
+ array(
+ self::QUERY_FLAG => '1',
+ 'sp_team' => (string) absint( $team_id ),
+ 'sp_season' => $season_id > 0 ? (string) absint( $season_id ) : '',
+ 'paper' => (string) $paper,
+ ),
+ home_url( '/' )
+ );
+ }
+
+ /**
+ * Default option payload.
+ *
+ * @return array
+ */
+ private function default_settings() {
+ return array(
+ 'calendar_feed_url' => '',
+ 'sync_interval_minutes' => 60,
+ 'venue_color_overrides' => array(),
+ 'venue_use_team_primary'=> array(),
+ );
+ }
+
+ /**
+ * Current settings with defaults.
+ *
+ * @return array
+ */
+ private function get_settings() {
+ return wp_parse_args( get_option( self::OPTION_KEY, array() ), $this->default_settings() );
+ }
+
+ /**
+ * Whether SportsPress is available.
+ *
+ * @return bool
+ */
+ private function sportspress_available() {
+ if ( class_exists( 'SportsPress' ) ) {
+ return true;
+ }
+
+ return post_type_exists( 'sp_team' ) && post_type_exists( 'sp_event' );
+ }
+
+ /**
+ * Sanitize venue color overrides.
+ *
+ * @param mixed $raw Raw value.
+ * @return array
+ */
+ private function sanitize_venue_color_overrides( $raw ) {
+ if ( ! is_array( $raw ) ) {
+ return array();
+ }
+
+ $sanitized = array();
+ foreach ( $raw as $season_key => $season_values ) {
+ $season_id = absint( (string) $season_key );
+ if ( $season_id <= 0 || ! is_array( $season_values ) ) {
+ continue;
+ }
+
+ foreach ( $season_values as $venue_key => $color ) {
+ $venue_id = absint( (string) $venue_key );
+ if ( $venue_id <= 0 || ! is_string( $color ) ) {
+ continue;
+ }
+
+ $hex = $this->sanitize_hex_color( $color );
+ if ( '' === $hex ) {
+ continue;
+ }
+
+ $sanitized[ (string) $season_id ][ (string) $venue_id ] = $this->adjust_for_white_text( $hex, self::MIN_WHITE_CONTRAST );
+ }
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Sanitize venue primary flags.
+ *
+ * @param mixed $raw Raw value.
+ * @return array
+ */
+ private function sanitize_venue_primary_flags( $raw ) {
+ if ( ! is_array( $raw ) ) {
+ return array();
+ }
+
+ $sanitized = array();
+ foreach ( $raw as $season_key => $season_values ) {
+ $season_id = absint( (string) $season_key );
+ if ( $season_id <= 0 || ! is_array( $season_values ) ) {
+ continue;
+ }
+
+ foreach ( $season_values as $venue_key => $value ) {
+ $venue_id = absint( (string) $venue_key );
+ if ( $venue_id <= 0 ) {
+ continue;
+ }
+
+ if ( is_scalar( $value ) && '1' === (string) $value ) {
+ $sanitized[ (string) $season_id ][ (string) $venue_id ] = '1';
+ }
+ }
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Get seasons list.
+ *
+ * @return array
+ */
+ private function get_seasons() {
+ $terms = get_terms(
+ array(
+ 'taxonomy' => 'sp_season',
+ 'hide_empty' => false,
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ )
+ );
+
+ if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
+ return array();
+ }
+
+ return $terms;
+ }
+
+ /**
+ * Resolve selected season ID.
+ *
+ * @return int
+ */
+ private function selected_season_id() {
+ $requested = isset( $_GET['season_id'] ) ? absint( (string) wp_unslash( $_GET['season_id'] ) ) : 0;
+ if ( $requested > 0 ) {
+ return $requested;
+ }
+
+ $current = absint( (string) get_option( 'sportspress_season', '0' ) );
+ if ( $current > 0 ) {
+ return $current;
+ }
+
+ $seasons = $this->get_seasons();
+ if ( isset( $seasons[0] ) && is_object( $seasons[0] ) && isset( $seasons[0]->term_id ) ) {
+ return (int) $seasons[0]->term_id;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Get venues for a season.
+ *
+ * @param int $season_id Season ID.
+ * @return array
+ */
+ private function get_venues_for_season( $season_id ) {
+ if ( $season_id <= 0 ) {
+ return array();
+ }
+
+ $events = get_posts(
+ array(
+ 'post_type' => 'sp_event',
+ 'post_status' => array( 'publish', 'future' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'tax_query' => array(
+ array(
+ 'taxonomy' => 'sp_season',
+ 'field' => 'term_id',
+ 'terms' => array( $season_id ),
+ ),
+ ),
+ )
+ );
+
+ if ( ! is_array( $events ) || empty( $events ) ) {
+ return array();
+ }
+
+ $terms = wp_get_object_terms(
+ $events,
+ 'sp_venue',
+ array(
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ )
+ );
+
+ if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
+ return array();
+ }
+
+ $venues = array();
+ foreach ( $terms as $term ) {
+ if ( ! is_object( $term ) || ! isset( $term->term_id, $term->name ) ) {
+ continue;
+ }
+
+ $venues[] = array(
+ 'id' => (int) $term->term_id,
+ 'name' => (string) $term->name,
+ );
+ }
+
+ return $venues;
+ }
+
+ /**
+ * Get stored venue color overrides.
+ *
+ * @return array
+ */
+ private function get_venue_color_overrides() {
+ $settings = $this->get_settings();
+
+ return isset( $settings['venue_color_overrides'] ) && is_array( $settings['venue_color_overrides'] ) ? $settings['venue_color_overrides'] : array();
+ }
+
+ /**
+ * Get stored venue primary flags.
+ *
+ * @return array
+ */
+ private function get_venue_primary_flags() {
+ $settings = $this->get_settings();
+
+ return isset( $settings['venue_use_team_primary'] ) && is_array( $settings['venue_use_team_primary'] ) ? $settings['venue_use_team_primary'] : array();
+ }
+
+ /**
+ * Collect event entries for the calendar.
+ *
+ * @param int $team_id Team ID.
+ * @param int $season_id Season ID.
+ * @return array
+ */
+ private function get_schedule_entries( $team_id, $season_id ) {
+ $args = array(
+ 'post_type' => 'sp_event',
+ 'post_status' => array( 'publish', 'future' ),
+ 'posts_per_page' => -1,
+ 'orderby' => 'date',
+ 'order' => 'ASC',
+ 'no_found_rows' => true,
+ 'meta_query' => array(
+ array(
+ 'key' => 'sp_team',
+ 'value' => array( (string) $team_id ),
+ 'compare' => 'IN',
+ ),
+ ),
+ );
+
+ if ( $season_id > 0 ) {
+ $args['tax_query'] = array(
+ array(
+ 'taxonomy' => 'sp_season',
+ 'field' => 'term_id',
+ 'terms' => array( $season_id ),
+ ),
+ );
+ }
+
+ $query = new WP_Query( $args );
+ $entries = array();
+
+ foreach ( $query->posts as $event ) {
+ $event_id = is_object( $event ) ? (int) $event->ID : 0;
+ if ( $event_id <= 0 ) {
+ continue;
+ }
+
+ $teams = array_values( array_unique( array_map( 'intval', get_post_meta( $event_id, 'sp_team', false ) ) ) );
+ if ( ! in_array( $team_id, $teams, true ) ) {
+ continue;
+ }
+
+ $timestamp = (int) get_post_time( 'U', true, $event_id, true );
+ if ( $timestamp <= 0 ) {
+ continue;
+ }
+
+ $opponent_id = 0;
+ foreach ( $teams as $team_option_id ) {
+ if ( $team_option_id !== $team_id ) {
+ $opponent_id = $team_option_id;
+ break;
+ }
+ }
+
+ $venues = get_the_terms( $event_id, 'sp_venue' );
+ $venue_name = '';
+ $venue_id = 0;
+ if ( is_array( $venues ) && isset( $venues[0] ) && is_object( $venues[0] ) && isset( $venues[0]->name ) ) {
+ $venue_name = (string) $venues[0]->name;
+ $venue_id = isset( $venues[0]->term_id ) ? (int) $venues[0]->term_id : 0;
+ }
+
+ $entries[] = array(
+ 'event_id' => $event_id,
+ 'day_key' => wp_date( 'Y-m-d', $timestamp ),
+ 'month_key' => wp_date( 'Y-m', $timestamp ),
+ 'timestamp' => $timestamp,
+ 'is_home' => isset( $teams[0] ) && $teams[0] === $team_id,
+ 'opponent_id' => $opponent_id,
+ 'opponent_name' => $opponent_id > 0 ? $this->get_team_label( $opponent_id ) : __( 'TBD', 'tonys-sportspress-enhancements' ),
+ 'event_time' => function_exists( 'sp_get_time' ) ? sp_get_time( $event_id ) : get_post_time( get_option( 'time_format' ), false, $event_id, true ),
+ 'venue_name' => $venue_name,
+ 'venue_id' => $venue_id,
+ 'venue_key' => $venue_id > 0 ? 'v:' . $venue_id : 'n:' . strtolower( $venue_name ),
+ );
+ }
+
+ wp_reset_postdata();
+
+ usort(
+ $entries,
+ function ( $left, $right ) {
+ return (int) $left['timestamp'] <=> (int) $right['timestamp'];
+ }
+ );
+
+ return $entries;
+ }
+
+ /**
+ * Build an ordered list of month keys.
+ *
+ * @param array $entries Schedule entries.
+ * @return array
+ */
+ private function get_month_keys( $entries ) {
+ if ( empty( $entries ) ) {
+ return array( wp_date( 'Y-m' ) );
+ }
+
+ $first = (int) $entries[0]['timestamp'];
+ $last = (int) $entries[ count( $entries ) - 1 ]['timestamp'];
+ $first_month = ( new DateTimeImmutable( wp_date( 'Y-m-01 00:00:00', $first ) ) )->modify( 'first day of this month' );
+ $last_month = ( new DateTimeImmutable( wp_date( 'Y-m-01 00:00:00', $last ) ) )->modify( 'first day of next month' );
+ $range = new DatePeriod( $first_month, new DateInterval( 'P1M' ), $last_month );
+ $months = array();
+
+ foreach ( $range as $month ) {
+ $months[] = $month->format( 'Y-m' );
+ }
+
+ return $months;
+ }
+
+ /**
+ * Render a single month grid.
+ *
+ * @param string $month_key Month key.
+ * @param array $entries_by_day Entries keyed by day.
+ * @param array $venue_colors Venue colors.
+ * @param array $team_palette Team palette.
+ */
+ private function render_month_grid( $month_key, $entries_by_day, $venue_colors, $team_palette ) {
+ $month = DateTimeImmutable::createFromFormat( 'Y-m-d', $month_key . '-01' );
+ if ( ! $month instanceof DateTimeImmutable ) {
+ return;
+ }
+
+ $month_label = wp_date( 'F', (int) $month->format( 'U' ) );
+ $days_in_month = (int) $month->format( 't' );
+ $leading_blanks = (int) $month->format( 'w' );
+
+ echo '';
+ echo '' . esc_html( $month_label ) . '
';
+ echo '';
+ foreach ( array( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ) as $day_name ) {
+ echo '' . esc_html( $day_name ) . '';
+ }
+ echo '
';
+ echo '';
+
+ for ( $i = 0; $i < $leading_blanks; $i++ ) {
+ echo '
';
+ }
+
+ for ( $day = 1; $day <= $days_in_month; $day++ ) {
+ $day_key = sprintf( '%s-%02d', $month_key, $day );
+ $day_entries = isset( $entries_by_day[ $day_key ] ) ? $entries_by_day[ $day_key ] : array();
+ $day_class = ! empty( $day_entries ) ? 'day has-events' : 'day no-events';
+ $day_style = '';
+
+ if ( ! empty( $day_entries ) ) {
+ $first_entry = $day_entries[0];
+ $first_is_home = ! empty( $first_entry['is_home'] );
+ $first_venue_key = isset( $first_entry['venue_key'] ) && is_string( $first_entry['venue_key'] ) ? $first_entry['venue_key'] : '';
+ $first_venue_color = ( '' !== $first_venue_key && isset( $venue_colors[ $first_venue_key ]['color'] ) ) ? (string) $venue_colors[ $first_venue_key ]['color'] : '';
+ $first_background = '' !== $first_venue_color ? $first_venue_color : ( $first_is_home ? $team_palette['primary'] : $team_palette['secondary'] );
+ $first_foreground = $this->get_readable_text_color( $first_background );
+ $first_background = $this->ensure_minimum_contrast( $first_background, $first_foreground, self::MIN_WHITE_CONTRAST );
+ $first_foreground = $this->get_readable_text_color( $first_background );
+ $day_num_shadow = '#000000' === strtoupper( $first_foreground ) ? 'none' : '0 1px 1px rgba(0,0,0,0.5)';
+ $day_style = ' style="--day-num-color:' . esc_attr( $first_foreground ) . ';--day-num-shadow:' . esc_attr( $day_num_shadow ) . ';"';
+ }
+
+ echo '
';
+ echo '
' . esc_html( (string) $day ) . '
';
+
+ if ( ! empty( $day_entries ) ) {
+ echo '
';
+ foreach ( $day_entries as $entry ) {
+ $is_home = ! empty( $entry['is_home'] );
+ $event_class = $is_home ? 'h' : 'a';
+ $venue_key = isset( $entry['venue_key'] ) && is_string( $entry['venue_key'] ) ? $entry['venue_key'] : '';
+ $venue_color = ( '' !== $venue_key && isset( $venue_colors[ $venue_key ]['color'] ) ) ? (string) $venue_colors[ $venue_key ]['color'] : '';
+ $opponent_id = isset( $entry['opponent_id'] ) ? (int) $entry['opponent_id'] : 0;
+ $logo = $opponent_id > 0 ? get_the_post_thumbnail( $opponent_id, 'medium', array( 'class' => 'event-logo-img', 'loading' => 'eager', 'decoding' => 'async' ) ) : '';
+ $has_logo = '' !== $logo;
+ $event_background = '' !== $venue_color ? $venue_color : ( $is_home ? $team_palette['primary'] : $team_palette['secondary'] );
+ $event_foreground = $this->get_readable_text_color( $event_background );
+ $event_background = $this->ensure_minimum_contrast( $event_background, $event_foreground, self::MIN_WHITE_CONTRAST );
+ $event_foreground = $this->get_readable_text_color( $event_background );
+ $event_style = ' style="--event-bg:' . esc_attr( $event_background ) . ';--event-fg:' . esc_attr( $event_foreground ) . ';"';
+
+ echo '
';
+ echo '';
+ if ( $has_logo ) {
+ echo wp_kses_post( $logo );
+ } else {
+ echo '' . esc_html( isset( $entry['opponent_name'] ) ? (string) $entry['opponent_name'] : '' ) . '';
+ }
+ echo '
';
+ echo '' . esc_html( $is_home ? 'H' : 'A' ) . '';
+ echo '' . esc_html( isset( $entry['event_time'] ) ? (string) $entry['event_time'] : '' ) . '
';
+ echo '';
+ }
+ echo '
';
+ }
+
+ echo '
';
+ }
+
+ $rendered_cells = $leading_blanks + $days_in_month;
+ $remaining = 7 - ( $rendered_cells % 7 );
+ if ( $remaining < 7 ) {
+ for ( $i = 0; $i < $remaining; $i++ ) {
+ echo '
';
+ }
+ }
+
+ echo '
';
+ echo '';
+ }
+
+ /**
+ * Resolve team label.
+ *
+ * @param int $team_id Team ID.
+ * @return string
+ */
+ private function get_team_label( $team_id ) {
+ if ( function_exists( 'sp_team_abbreviation' ) ) {
+ $label = (string) sp_team_abbreviation( $team_id );
+ if ( '' !== $label ) {
+ return $label;
+ }
+ }
+
+ $title = get_the_title( $team_id );
+
+ return is_string( $title ) && '' !== $title ? $title : __( 'TBD', 'tonys-sportspress-enhancements' );
+ }
+
+ /**
+ * Resolve venue color.
+ *
+ * @param string $venue_name Venue name.
+ * @param int $season_id Season ID.
+ * @param int $venue_id Venue ID.
+ * @param string $team_primary Team primary color.
+ * @return string
+ */
+ private function get_venue_color( $venue_name, $season_id, $venue_id, $team_primary ) {
+ if ( $this->should_use_team_primary_for_venue( $season_id, $venue_id ) && '' !== $this->sanitize_color( $team_primary ) ) {
+ return $this->sanitize_color( $team_primary );
+ }
+
+ $custom = $this->get_custom_venue_color( $season_id, $venue_id );
+ if ( '' !== $custom ) {
+ return $custom;
+ }
+
+ $palette = array( '#1D4ED8', '#DC2626', '#A16207', '#15803D', '#0E7490', '#BE185D', '#6D28D9', '#C2410C' );
+ $index = abs( crc32( strtolower( $venue_name ) ) ) % count( $palette );
+
+ return $palette[ $index ];
+ }
+
+ /**
+ * Resolve primary team color without site fallbacks.
+ *
+ * @param int $team_id Team ID.
+ * @return string
+ */
+ private function get_strict_team_primary_color( $team_id ) {
+ $team_colors = get_post_meta( $team_id, 'sp_colors', true );
+ if ( is_array( $team_colors ) ) {
+ $primary_keys = array( 'primary', 'link', 'heading', 'background', 'text' );
+ foreach ( $primary_keys as $key ) {
+ if ( isset( $team_colors[ $key ] ) && is_string( $team_colors[ $key ] ) ) {
+ $hex = $this->sanitize_color( $team_colors[ $key ] );
+ if ( '' !== $hex ) {
+ return $hex;
+ }
+ }
+ }
+
+ foreach ( $team_colors as $value ) {
+ if ( is_string( $value ) ) {
+ $hex = $this->sanitize_color( $value );
+ if ( '' !== $hex ) {
+ return $hex;
+ }
+ }
+ }
+ }
+
+ foreach ( array( get_post_meta( $team_id, 'sp_color', true ), get_post_meta( $team_id, 'asc_sp_primary_color', true ) ) as $candidate ) {
+ $hex = $this->sanitize_color( (string) $candidate );
+ if ( '' !== $hex ) {
+ return $hex;
+ }
+ }
+
+ return '#1B76D1';
+ }
+
+ /**
+ * Get stored custom venue color.
+ *
+ * @param int $season_id Season ID.
+ * @param int $venue_id Venue ID.
+ * @return string
+ */
+ private function get_custom_venue_color( $season_id, $venue_id ) {
+ if ( $season_id <= 0 || $venue_id <= 0 ) {
+ return '';
+ }
+
+ $overrides = $this->get_venue_color_overrides();
+ $season_key = (string) $season_id;
+ $venue_key = (string) $venue_id;
+
+ if ( ! isset( $overrides[ $season_key ] ) || ! is_array( $overrides[ $season_key ] ) ) {
+ return '';
+ }
+
+ if ( ! isset( $overrides[ $season_key ][ $venue_key ] ) || ! is_string( $overrides[ $season_key ][ $venue_key ] ) ) {
+ return '';
+ }
+
+ $hex = strtoupper( trim( $overrides[ $season_key ][ $venue_key ] ) );
+ if ( 1 !== preg_match( '/^#[0-9A-F]{6}$/', $hex ) ) {
+ return '';
+ }
+
+ return $hex;
+ }
+
+ /**
+ * Get header logo markup.
+ *
+ * @return string
+ */
+ private function get_header_brand_logo() {
+ $themeboy_options = get_option( 'themeboy', array() );
+ if ( is_array( $themeboy_options ) && isset( $themeboy_options['logo_url'] ) && is_string( $themeboy_options['logo_url'] ) ) {
+ $themeboy_logo_url = esc_url( set_url_scheme( $themeboy_options['logo_url'] ) );
+ if ( '' !== $themeboy_logo_url ) {
+ return '
';
+ }
+ }
+
+ $custom_logo_html = get_custom_logo();
+ if ( is_string( $custom_logo_html ) && '' !== trim( $custom_logo_html ) ) {
+ return $custom_logo_html;
+ }
+
+ if ( function_exists( 'render_block' ) ) {
+ $block_logo = render_block(
+ array(
+ 'blockName' => 'core/site-logo',
+ 'attrs' => array( 'width' => 72 ),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '',
+ 'innerContent' => array(),
+ )
+ );
+ if ( is_string( $block_logo ) && false !== strpos( $block_logo, '
0 ) {
+ $custom_logo = wp_get_attachment_image( $custom_logo_id, array( 72, 72 ), false, array( 'class' => 'league-logo-img' ) );
+ if ( is_string( $custom_logo ) && '' !== $custom_logo ) {
+ return $custom_logo;
+ }
+ }
+
+ $site_logo_id = absint( (string) get_option( 'site_logo', 0 ) );
+ if ( $site_logo_id > 0 ) {
+ $site_logo = wp_get_attachment_image( $site_logo_id, array( 72, 72 ), false, array( 'class' => 'league-logo-img' ) );
+ if ( is_string( $site_logo ) && '' !== $site_logo ) {
+ return $site_logo;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Check whether venue should inherit team primary color.
+ *
+ * @param int $season_id Season ID.
+ * @param int $venue_id Venue ID.
+ * @return bool
+ */
+ private function should_use_team_primary_for_venue( $season_id, $venue_id ) {
+ if ( $season_id <= 0 || $venue_id <= 0 ) {
+ return false;
+ }
+
+ $flags = $this->get_venue_primary_flags();
+ $season_key = (string) $season_id;
+ $venue_key = (string) $venue_id;
+
+ return isset( $flags[ $season_key ], $flags[ $season_key ][ $venue_key ] ) && '1' === (string) $flags[ $season_key ][ $venue_key ];
+ }
+
+ /**
+ * Resolve team palette.
+ *
+ * @param int $team_id Team ID.
+ * @return array
+ */
+ private function get_team_color_palette( $team_id ) {
+ $option_colors = get_option( 'sportspress_frontend_css_colors', array() );
+ $team_colors = get_post_meta( $team_id, 'sp_colors', true );
+ $primary = '';
+ $secondary = '';
+ $accent = '';
+
+ if ( is_array( $team_colors ) ) {
+ $primary = $this->sanitize_color( (string) ( isset( $team_colors['primary'] ) ? $team_colors['primary'] : ( isset( $team_colors['link'] ) ? $team_colors['link'] : '' ) ) );
+ $secondary = $this->sanitize_color( (string) ( isset( $team_colors['heading'] ) ? $team_colors['heading'] : ( isset( $team_colors['background'] ) ? $team_colors['background'] : '' ) ) );
+ $accent = $this->sanitize_color( (string) ( isset( $team_colors['text'] ) ? $team_colors['text'] : '' ) );
+ }
+
+ if ( '' === $primary ) {
+ $primary = $this->sanitize_color( (string) get_post_meta( $team_id, 'sp_color', true ) );
+ }
+ if ( '' === $secondary ) {
+ $secondary = $this->sanitize_color( (string) get_post_meta( $team_id, 'asc_sp_secondary_color', true ) );
+ }
+ if ( '' === $accent ) {
+ $accent = $this->sanitize_color( (string) get_post_meta( $team_id, 'asc_sp_accent_color', true ) );
+ }
+ if ( '' === $primary ) {
+ $primary = $this->sanitize_color( is_array( $option_colors ) && isset( $option_colors['primary'] ) ? (string) $option_colors['primary'] : '' );
+ }
+ if ( '' === $secondary ) {
+ $secondary = $this->sanitize_color( is_array( $option_colors ) && isset( $option_colors['link'] ) ? (string) $option_colors['link'] : '' );
+ }
+ if ( '' === $accent ) {
+ $accent = $this->sanitize_color( is_array( $option_colors ) && isset( $option_colors['heading'] ) ? (string) $option_colors['heading'] : '' );
+ }
+ if ( '' === $primary ) {
+ $primary = '#1B76D1';
+ }
+ if ( '' === $secondary ) {
+ $secondary = '#8B3F1F';
+ }
+ if ( '' === $accent ) {
+ $accent = $primary;
+ }
+
+ return array(
+ 'primary' => $primary,
+ 'secondary' => $secondary,
+ 'accent' => $accent,
+ 'ink' => '#111827',
+ 'muted_ink' => '#334155',
+ );
+ }
+
+ /**
+ * Sanitize a six-digit hex color.
+ *
+ * @param string $color Input color.
+ * @return string
+ */
+ private function sanitize_color( $color ) {
+ $value = trim( $color );
+ if ( 1 !== preg_match( '/^#[0-9a-fA-F]{6}$/', $value ) ) {
+ return '';
+ }
+
+ return strtoupper( $value );
+ }
+
+ /**
+ * Get readable foreground.
+ *
+ * @param string $background_hex Background.
+ * @return string
+ */
+ private function get_readable_text_color( $background_hex ) {
+ $hex = ltrim( trim( $background_hex ), '#' );
+ if ( 1 !== preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) {
+ return '#FFFFFF';
+ }
+
+ $red = hexdec( substr( $hex, 0, 2 ) );
+ $green = hexdec( substr( $hex, 2, 2 ) );
+ $blue = hexdec( substr( $hex, 4, 2 ) );
+ $luminance = $this->relative_luminance( $red, $green, $blue );
+ $contrast_white = 1.05 / ( $luminance + 0.05 );
+ $contrast_black = ( $luminance + 0.05 ) / 0.05;
+
+ return $contrast_black > $contrast_white ? '#000000' : '#FFFFFF';
+ }
+
+ /**
+ * Ensure minimum contrast.
+ *
+ * @param string $background_hex Background.
+ * @param string $foreground_hex Foreground.
+ * @param float $target_ratio Ratio target.
+ * @return string
+ */
+ private function ensure_minimum_contrast( $background_hex, $foreground_hex, $target_ratio ) {
+ $background_rgb = $this->hex_to_rgb( $background_hex );
+ $foreground_rgb = $this->hex_to_rgb( $foreground_hex );
+ if ( null === $background_rgb || null === $foreground_rgb ) {
+ return $background_hex;
+ }
+
+ $current = $background_rgb;
+ $mix_with = '#FFFFFF' === strtoupper( $foreground_hex ) ? array( 0, 0, 0 ) : array( 255, 255, 255 );
+
+ for ( $i = 0; $i < 24; $i++ ) {
+ if ( $this->contrast_ratio( $current, $foreground_rgb ) >= $target_ratio ) {
+ return $this->rgb_to_hex( $current );
+ }
+
+ $current = $this->mix_rgb( $current, $mix_with, 0.08 );
+ }
+
+ return $this->rgb_to_hex( $current );
+ }
+
+ /**
+ * Sanitize hex color.
+ *
+ * @param string $value Input color.
+ * @return string
+ */
+ private function sanitize_hex_color( $value ) {
+ $hex = strtoupper( trim( $value ) );
+ if ( 1 !== preg_match( '/^#[0-9A-F]{6}$/', $hex ) ) {
+ return '';
+ }
+
+ return $hex;
+ }
+
+ /**
+ * Force dark-enough background for white text.
+ *
+ * @param string $background_hex Background color.
+ * @param float $min_ratio Minimum ratio.
+ * @return string
+ */
+ private function adjust_for_white_text( $background_hex, $min_ratio ) {
+ $rgb = $this->hex_to_rgb( $background_hex );
+ if ( null === $rgb ) {
+ return '#1E3A8A';
+ }
+
+ $white = array( 255, 255, 255 );
+ $current = $rgb;
+ for ( $i = 0; $i < 30; $i++ ) {
+ if ( $this->contrast_ratio( $current, $white ) >= $min_ratio ) {
+ return $this->rgb_to_hex( $current );
+ }
+
+ $current = $this->mix_rgb( $current, array( 0, 0, 0 ), 0.08 );
+ }
+
+ return $this->rgb_to_hex( $current );
+ }
+
+ /**
+ * Contrast ratio.
+ *
+ * @param array $background_rgb Background RGB.
+ * @param array $foreground_rgb Foreground RGB.
+ * @return float
+ */
+ private function contrast_ratio( $background_rgb, $foreground_rgb ) {
+ $bg_luminance = $this->relative_luminance( $background_rgb[0], $background_rgb[1], $background_rgb[2] );
+ $fg_luminance = $this->relative_luminance( $foreground_rgb[0], $foreground_rgb[1], $foreground_rgb[2] );
+ $lighter = max( $bg_luminance, $fg_luminance );
+ $darker = min( $bg_luminance, $fg_luminance );
+
+ return ( $lighter + 0.05 ) / ( $darker + 0.05 );
+ }
+
+ /**
+ * Convert hex to RGB.
+ *
+ * @param string $hex Hex input.
+ * @return array|null
+ */
+ private function hex_to_rgb( $hex ) {
+ $value = ltrim( trim( $hex ), '#' );
+ if ( 1 !== preg_match( '/^[0-9a-fA-F]{6}$/', $value ) ) {
+ return null;
+ }
+
+ return array(
+ hexdec( substr( $value, 0, 2 ) ),
+ hexdec( substr( $value, 2, 2 ) ),
+ hexdec( substr( $value, 4, 2 ) ),
+ );
+ }
+
+ /**
+ * Convert RGB to hex.
+ *
+ * @param array $rgb RGB triplet.
+ * @return string
+ */
+ private function rgb_to_hex( $rgb ) {
+ return sprintf( '#%02X%02X%02X', $rgb[0], $rgb[1], $rgb[2] );
+ }
+
+ /**
+ * Mix two RGB values.
+ *
+ * @param array $source Source RGB.
+ * @param array $target Target RGB.
+ * @param float $weight Weight.
+ * @return array
+ */
+ private function mix_rgb( $source, $target, $weight ) {
+ $mix = function ( $start, $end, $ratio ) {
+ return (int) max( 0, min( 255, round( $start + ( $end - $start ) * $ratio ) ) );
+ };
+
+ return array(
+ $mix( $source[0], $target[0], $weight ),
+ $mix( $source[1], $target[1], $weight ),
+ $mix( $source[2], $target[2], $weight ),
+ );
+ }
+
+ /**
+ * Relative luminance.
+ *
+ * @param int $red Red channel.
+ * @param int $green Green channel.
+ * @param int $blue Blue channel.
+ * @return float
+ */
+ private function relative_luminance( $red, $green, $blue ) {
+ $r = $this->channel_luminance( $red / 255 );
+ $g = $this->channel_luminance( $green / 255 );
+ $b = $this->channel_luminance( $blue / 255 );
+
+ return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
+ }
+
+ /**
+ * Luminance for a single channel.
+ *
+ * @param float $channel Channel value.
+ * @return float
+ */
+ private function channel_luminance( $channel ) {
+ if ( $channel <= 0.03928 ) {
+ return $channel / 12.92;
+ }
+
+ return ( ( $channel + 0.055 ) / 1.055 ) ** 2.4;
+ }
+
+ /**
+ * Determine sheet layout.
+ *
+ * @param int $month_count Number of months.
+ * @param string $paper Paper size.
+ * @return array
+ */
+ private function get_sheet_layout( $month_count, $paper ) {
+ $count = max( 1, (int) $month_count );
+ $columns = 1;
+
+ if ( $count <= 4 ) {
+ $columns = 2;
+ } elseif ( $count <= 9 ) {
+ $columns = 3;
+ } elseif ( $count <= 16 ) {
+ $columns = 4;
+ } else {
+ $columns = 5;
+ }
+
+ $rows = (int) ceil( $count / $columns );
+
+ $font_scale = 1.0;
+ if ( $count >= 3 && $count <= 4 ) {
+ $font_scale = 0.9;
+ } elseif ( $count >= 5 && $count <= 6 ) {
+ $font_scale = 0.78;
+ } elseif ( $count > 6 && $count <= 9 ) {
+ $font_scale = 0.68;
+ } elseif ( $count > 9 ) {
+ $font_scale = 0.58;
+ }
+
+ $sheet_scale = 1.0;
+ if ( $count > 12 ) {
+ $sheet_scale = max( 0.42, 12 / $count );
+ }
+
+ $day_aspect = '4 / 5';
+ if ( $count >= 5 && $count <= 6 ) {
+ $day_aspect = '1 / 1';
+ } elseif ( $count >= 7 && $count <= 9 ) {
+ $day_aspect = '6 / 5';
+ } elseif ( $count > 9 ) {
+ $day_aspect = '5 / 4';
+ }
+
+ if ( 'ledger' === $paper && $count <= 2 ) {
+ $day_aspect = '1 / 1';
+ }
+
+ return array(
+ 'columns' => $columns,
+ 'rows' => $rows,
+ 'font_scale' => $font_scale,
+ 'day_aspect' => $day_aspect,
+ 'sheet_scale' => $sheet_scale,
+ );
+ }
+ }
+}
+
+/**
+ * Bootstrap printable calendars after plugins load.
+ */
+function tony_sportspress_printable_calendars_boot() {
+ Tony_Sportspress_Printable_Calendars::instance()->boot();
+}
+add_action( 'plugins_loaded', 'tony_sportspress_printable_calendars_boot' );
diff --git a/readme.md b/readme.md
index 85f84d5..c95d54b 100644
--- a/readme.md
+++ b/readme.md
@@ -9,12 +9,14 @@ Tony's SportsPress Enhancements is a collection of add-ons for the [SportsPress]
- **Custom event permalinks** for `sp_event` post types, including season and team slugs.
- **Open Graph meta tags** for events, with dynamic titles, descriptions, and images.
- **Automatic featured image generation** for events, combining team colors and logos into a shareable image.
+- **Printable team schedules** with season-aware venue colors and a print-friendly calendar layout.
## Features
- Custom rewrite rules and permalinks for SportsPress events.
- Open Graph integration for better social sharing.
- Dynamic, cached event images based on team data.
+- Printable schedule pages linked from team profiles.
- Compatible with WordPress 4.5+ and PHP 5.6+.
## Installation
@@ -34,4 +36,4 @@ A: When an event is viewed or shared, a featured image is generated using the pr
---
-*This plugin is not affiliated with or endorsed by ThemeBoy or the official SportsPress plugin.*
\ No newline at end of file
+*This plugin is not affiliated with or endorsed by ThemeBoy or the official SportsPress plugin.*
diff --git a/tonys-sportspress-enhancements.php b/tonys-sportspress-enhancements.php
index 0231ebc..9be520b 100644
--- a/tonys-sportspress-enhancements.php
+++ b/tonys-sportspress-enhancements.php
@@ -12,6 +12,21 @@
* @package Tonys_Sportspress_Enhancements
*/
+if ( ! defined( 'TONY_SPORTSPRESS_ENHANCEMENTS_VERSION' ) ) {
+ define( 'TONY_SPORTSPRESS_ENHANCEMENTS_VERSION', '0.1.5' );
+}
+
+if ( ! defined( 'TONY_SPORTSPRESS_ENHANCEMENTS_FILE' ) ) {
+ define( 'TONY_SPORTSPRESS_ENHANCEMENTS_FILE', __FILE__ );
+}
+
+if ( ! defined( 'TONY_SPORTSPRESS_ENHANCEMENTS_DIR' ) ) {
+ define( 'TONY_SPORTSPRESS_ENHANCEMENTS_DIR', plugin_dir_path( __FILE__ ) );
+}
+
+if ( ! defined( 'TONY_SPORTSPRESS_ENHANCEMENTS_URL' ) ) {
+ define( 'TONY_SPORTSPRESS_ENHANCEMENTS_URL', plugin_dir_url( __FILE__ ) );
+}
// Include other files here
require_once plugin_dir_path(__FILE__) . 'includes/open-graph-tags.php';
@@ -21,3 +36,4 @@ require_once plugin_dir_path(__FILE__) . 'includes/sp-event-csv.php';
require_once plugin_dir_path(__FILE__) . 'includes/sp-event-admin-week-filter.php';
require_once plugin_dir_path(__FILE__) . 'includes/sp-event-quick-edit-officials.php';
require_once plugin_dir_path(__FILE__) . 'includes/sp-event-team-ordering.php';
+require_once plugin_dir_path(__FILE__) . 'includes/sp-printable-calendars.php';