From d2ff863ca568b0b0667d90074ca7c7332c7586f3 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Fri, 27 Mar 2026 16:22:44 -0500 Subject: [PATCH] Add printable calendars feature --- assets/print-calendar.css | 474 +++++++++ includes/sp-printable-calendars.php | 1521 +++++++++++++++++++++++++++ readme.md | 4 +- tonys-sportspress-enhancements.php | 16 + 4 files changed, 2014 insertions(+), 1 deletion(-) create mode 100644 assets/print-calendar.css create mode 100644 includes/sp-printable-calendars.php 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 ''; + } + + /** + * 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 '
'; + settings_fields( self::OPTION_GROUP ); + + echo '

' . esc_html__( 'Field Colors By Season', 'tonys-sportspress-enhancements' ) . '

'; + echo '

' . esc_html__( 'Pick venue colors per season. Colors are darkened automatically when needed so white text still reads clearly.', 'tonys-sportspress-enhancements' ) . '

'; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + echo '
'; + echo '

' . esc_html__( 'Suggested dark palette:', 'tonys-sportspress-enhancements' ) . '

'; + echo '
'; + foreach ( $this->suggested_palette as $swatch ) { + echo ''; + echo ''; + echo esc_html( $swatch ); + echo ''; + } + echo '
'; + echo '
'; + + if ( empty( $venues ) ) { + echo '

' . esc_html__( 'No venues found for this season yet.', 'tonys-sportspress-enhancements' ) . '

'; + } else { + echo ''; + echo ''; + echo ''; + + $palette_count = count( $this->suggested_palette ); + foreach ( $venues as $index => $venue ) { + $venue_id = (int) $venue['id']; + $venue_name = isset( $venue['name'] ) ? (string) $venue['name'] : ''; + $saved = isset( $season_overrides[ (string) $venue_id ] ) && is_string( $season_overrides[ (string) $venue_id ] ) ? $season_overrides[ (string) $venue_id ] : ''; + $suggested = $this->suggested_palette[ $index % max( 1, $palette_count ) ]; + $value = '' !== $saved ? $saved : $suggested; + $adjusted = $this->adjust_for_white_text( $value, self::MIN_WHITE_CONTRAST ); + $name = self::OPTION_KEY . '[venue_color_overrides][' . $season_key . '][' . $venue_id . ']'; + $primary_name = self::OPTION_KEY . '[venue_use_team_primary][' . $season_key . '][' . $venue_id . ']'; + $use_primary = isset( $season_primary_flags[ (string) $venue_id ] ) && '1' === $season_primary_flags[ (string) $venue_id ]; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + + echo ''; + echo '
' . esc_html__( 'Field / Venue', 'tonys-sportspress-enhancements' ) . '' . esc_html__( 'Background Color', 'tonys-sportspress-enhancements' ) . '' . esc_html__( 'Use Team Primary', 'tonys-sportspress-enhancements' ) . '' . esc_html__( 'Preview', 'tonys-sportspress-enhancements' ) . '
' . esc_html( $venue_name ) . '' . esc_html__( 'Sample', 'tonys-sportspress-enhancements' ) . '
'; + } + + submit_button( __( 'Save Settings', 'tonys-sportspress-enhancements' ) ); + 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 '
'; + 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 '
'; + } + + 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';