diff --git a/public/conferences/index.php b/public/conferences/index.php index 4e4031ec17..e78c3870b2 100644 --- a/public/conferences/index.php +++ b/public/conferences/index.php @@ -1,5 +1,6 @@ ['home.css'], ]); -$content = "
"; -$panels = '

Want to see your conference appear here?

'; +$today = gmdate('Y-m-d'); +$schedule = new ConferenceSchedule((new NewsHandler())->getConferences(), $today); + +// The page has two views, switched via ?view=: the default view, which lists +// conferences only (targeted at attendees/sponsors), and the call-for-papers +// view, which lists CFPs only, split into open and closed (targeting speakers). +$view = ($_GET['view'] ?? null) === 'cfps' ? 'cfps' : 'conferences'; + +// Build nav for switching between views +$viewLinks = [ + 'conferences' => ['label' => 'Conferences', 'href' => '/conferences/'], + 'cfps' => ['label' => 'Calls for papers', 'href' => '/conferences/?view=cfps'], +]; + +$viewSwitcher = ''; -foreach ((new NewsHandler())->getConferences() as $entry) { +$renderEntry = static function (array $entry) use ($MYSITE, $today): string { $link = preg_replace('~^(http://php.net/|https://www.php.net/)~', '', $entry["id"]); $id = parse_url($entry["id"], PHP_URL_FRAGMENT); - $date = date_format(date_create($entry["updated"] ?? $entry["published"]), 'Y-m-d'); - $content .= '
'; - $content .= '

' . $entry["title"] . '

'; - $content .= '
'; + + $html = '
'; + $html .= '

' . $entry["title"] . '

'; + + if (isset($entry["finalTeaserDate"])) { + $isPast = $entry["finalTeaserDate"] < $today; + if (ConferenceSchedule::termFor($entry) === 'cfp') { + $label = $isPast ? 'CFP closed' : 'CFP closes'; + } else { + $label = $isPast ? 'Started' : 'Starts'; + } + $html .= '

' . $label . '

'; + } + + $html .= '
'; if (isset($entry["newsImage"])) { - $content .= sprintf('', $entry["newsImage"]["link"], $entry["newsImage"]["content"]); + $html .= sprintf('', $entry["newsImage"]["link"], $entry["newsImage"]["content"]); } - $content .= '
'; - $content .= '
'; - $content .= $entry["content"]; - $content .= '
'; - $content .= '
'; + $html .= '
'; + $html .= '
'; + $html .= $entry["content"]; + $html .= '
'; + $html .= '
'; + + return $html; +}; + +$content = "
"; +$content .= $viewSwitcher; + +if ($view === 'cfps') { + $content .= '

Open calls for papers

'; + + if ($schedule->openCallsForPapers === []) { + $content .= '

No open calls for papers right now.

'; + } else { + foreach ($schedule->openCallsForPapers as $entry) { + $content .= $renderEntry($entry); + } + } + + if ($schedule->closedCallsForPapers !== []) { + $content .= '

Closed calls for papers

'; + + foreach ($schedule->closedCallsForPapers as $entry) { + $content .= $renderEntry($entry); + } + } +} else { + $content .= '

Upcoming conferences

'; - $panels .= sprintf('

%s

', $entry["newsImage"]["link"] ?? $link, $entry["title"]); + if ($schedule->upcomingConferences === []) { + $content .= '

No upcoming conferences right now.

'; + } else { + foreach ($schedule->upcomingConferences as $entry) { + $content .= $renderEntry($entry); + } + } + + if ($schedule->pastConferences !== []) { + $content .= '

Past conferences

'; + + foreach ($schedule->pastConferences as $entry) { + $content .= $renderEntry($entry); + } + } } + $content .= "
"; echo $content; +// Build sidebar mirroring the active view: conferences grouped into upcoming/past, or CFPs into open/closed. +$panels = '

Want to see your conference appear here?

'; + +$renderPanels = static function (array $entries): string { + $html = ''; + foreach ($entries as $entry) { + $link = preg_replace('~^(http://php.net/|https://www.php.net/)~', '', $entry["id"]); + $html .= sprintf('

%s

', $entry["newsImage"]["link"] ?? $link, $entry["title"]); + } + + return $html; +}; + +if ($view === 'cfps') { + if ($schedule->openCallsForPapers !== []) { + $panels .= '

Open

' . $renderPanels($schedule->openCallsForPapers); + } + + if ($schedule->closedCallsForPapers !== []) { + $panels .= '

Closed

' . $renderPanels($schedule->closedCallsForPapers); + } +} else { + if ($schedule->upcomingConferences !== []) { + $panels .= '

Upcoming

' . $renderPanels($schedule->upcomingConferences); + } + + if ($schedule->pastConferences !== []) { + $panels .= '

Past

' . $renderPanels($schedule->pastConferences); + } +} + site_footer([ "atom" => "/feed.atom", "elephpants" => true, diff --git a/public/styles/home.css b/public/styles/home.css index 2edf051d5f..f0ad7fe335 100644 --- a/public/styles/home.css +++ b/public/styles/home.css @@ -90,22 +90,22 @@ } .hero__versions a.notes { - font-size:.875rem; - white-space:nowrap; + font-size: .875rem; + white-space: nowrap; } .hero__versions a { color: var(--background-text-color); - border:0; + border: 0; } .hero__versions a:hover, .hero__versions a:focus { - border-bottom:1px dotted; + border-bottom: 1px dotted; } .hero__version-link { - color:#fff !important; + color: #fff !important; display: inline-block; } @@ -151,11 +151,12 @@ /* Layout */ #layout-content { - border-right:.25rem solid #666; + border-right: .25rem solid #666; } + .home .newsentry .newstitle a:after { - content:"\20 \00bb"; - color:#666; + content: "\20 \00bb"; + color: #666; } p.archive { @@ -180,7 +181,7 @@ p.archive { } #layout-content { - width:70%; + width: 70%; } } @@ -216,3 +217,41 @@ aside.tips .social-media .icon-twitter { background-color: var(--dark-magenta-color) !important; border-color: var(--dark-magenta-color) !important; } + +/* Conference pages */ +.conference-views { + display: flex; + justify-content: flex-end; + gap: 1.5rem; + margin: 0 0 1.5rem; + border-bottom: 1px solid #ccc; +} + +.conference-views .conference-view { + padding: .5rem .25rem; + margin-bottom: -1px; + border-bottom: 3px solid transparent; + color: #666; + text-decoration: none; +} + +.conference-views .conference-view.is-active { + border-bottom-color: var(--dark-blue-color); + color: var(--dark-grey-color); + font-weight: 600; +} + +.newsentry .newsdate { + margin: -.75rem 0 .75rem; + color: #666; + font-size: .75rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; +} + +h2.conferences-divider { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid #ccc; +} \ No newline at end of file diff --git a/src/News/ConferenceSchedule.php b/src/News/ConferenceSchedule.php new file mode 100644 index 0000000000..f648cf2373 --- /dev/null +++ b/src/News/ConferenceSchedule.php @@ -0,0 +1,115 @@ + Conferences starting today or later, soonest first. */ + public private(set) array $upcomingConferences = []; + + /** @var list Conferences already started (or undated), most recent first. */ + public private(set) array $pastConferences = []; + + /** @var list CFPs whose submission deadline has not passed, soonest deadline first. */ + public private(set) array $openCallsForPapers = []; + + /** @var list CFPs whose deadline has passed (or is unknown), most recently closed first. */ + public private(set) array $closedCallsForPapers = []; + + /** + * @param list $entries conference/CFP entries, e.g. from NewsHandler::getConferences() + * @param string $today today's date as 'Y-m-d' + */ + public function __construct(array $entries, string $today) + { + foreach ($entries as $entry) { + $this->assignConferenceOrCfp($entry, $today); + } + + $this->upcomingConferences = self::sortedByDate($this->upcomingConferences, ascending: true); + $this->openCallsForPapers = self::sortedByDate($this->openCallsForPapers, ascending: true); + $this->pastConferences = self::sortedByDate($this->pastConferences, ascending: false); + $this->closedCallsForPapers = self::sortedByDate($this->closedCallsForPapers, ascending: false); + } + + /** + * The entry's primary term. A few posts announce a conference and open its CFP at once, + * carrying both terms; anything that is a conference is treated as a conference (and so + * placed by its date), leaving 'cfp' for CFP-only posts. + * + * @param array $entry + */ + public static function termFor(array $entry): ?string + { + $found = null; + foreach ($entry['category'] as $category) { + if ($category['term'] === 'conferences') { + return 'conferences'; + } + + if ($category['term'] === 'cfp') { + $found = 'cfp'; + } + } + + return $found; + } + + /** + * @param array $conferenceOrCfp + */ + private function assignConferenceOrCfp(array $conferenceOrCfp, string $today): void + { + $isFuture = isset($conferenceOrCfp['finalTeaserDate']) && $conferenceOrCfp['finalTeaserDate'] >= $today; + $isCfp = self::termFor($conferenceOrCfp) === 'cfp'; + + match (true) { + $isCfp && $isFuture => $this->openCallsForPapers[] = $conferenceOrCfp, + $isCfp => $this->closedCallsForPapers[] = $conferenceOrCfp, + $isFuture => $this->upcomingConferences[] = $conferenceOrCfp, + default => $this->pastConferences[] = $conferenceOrCfp, + }; + } + + /** + * Sort entries by finalTeaserDate ('Y-m-d'), keeping undated entries last. + * + * @param list $entries + * + * @return list + */ + private static function sortedByDate(array $entries, bool $ascending): array + { + usort($entries, static function (array $a, array $b) use ($ascending): int { + $aDate = $a['finalTeaserDate'] ?? ''; + $bDate = $b['finalTeaserDate'] ?? ''; + + // Push undated entries to the end whichever way the dated ones are ordered. + if ($aDate === '' || $bDate === '') { + return ($aDate === '' ? 1 : 0) <=> ($bDate === '' ? 1 : 0); + } + + return $ascending ? $aDate <=> $bDate : $bDate <=> $aDate; + }); + + return $entries; + } +} diff --git a/src/News/Entry.php b/src/News/Entry.php index 552aed883d..a08052c168 100755 --- a/src/News/Entry.php +++ b/src/News/Entry.php @@ -111,7 +111,12 @@ public function save(): self { $dom->preserveWhiteSpace = false; $item = $dom->createElementNs("http://www.w3.org/2005/Atom", "entry"); - $href = self::WEBROOT . ($this->isConference() ? '/conferences/index.php' : '/index.php'); + // Build the appropriate deep link depending on whether it's news, conference, or CFP + $href = self::WEBROOT . match (true) { + in_array('conferences', $this->categories, true) => '/conferences/index.php', + in_array('cfp', $this->categories, true) => '/conferences/index.php?view=cfps', + default => '/index.php', + }; $archive = self::WEBROOT . "/archive/" . date('Y', $_SERVER['REQUEST_TIME']) . ".php#{$this->id}"; $link = ($this->image['link'] ?? null) ?: $archive; diff --git a/tests/Unit/News/ConferenceScheduleTest.php b/tests/Unit/News/ConferenceScheduleTest.php new file mode 100644 index 0000000000..3b9a081545 --- /dev/null +++ b/tests/Unit/News/ConferenceScheduleTest.php @@ -0,0 +1,131 @@ +upcomingConferences)); + self::assertSame(['past'], self::ids($schedule->pastConferences)); + self::assertSame([], $schedule->openCallsForPapers); + self::assertSame([], $schedule->closedCallsForPapers); + } + + public function testUpcomingConferencesAreOrderedSoonestFirst(): void + { + $schedule = new ConferenceSchedule([ + self::entry('later', ['conferences'], '2020-09-01'), + self::entry('sooner', ['conferences'], '2020-07-01'), + self::entry('middle', ['conferences'], '2020-08-01'), + ], '2020-06-01'); + + self::assertSame(['sooner', 'middle', 'later'], self::ids($schedule->upcomingConferences)); + } + + public function testPastConferencesAreOrderedMostRecentFirstWithUndatedLast(): void + { + $schedule = new ConferenceSchedule([ + self::entry('older', ['conferences'], '2020-01-01'), + self::entry('undated', ['conferences'], null), + self::entry('newer', ['conferences'], '2020-05-01'), + ], '2020-06-01'); + + // A conference with no start date can't be placed, so it sorts last. + self::assertSame(['newer', 'older', 'undated'], self::ids($schedule->pastConferences)); + } + + public function testCfpsSplitIntoOpenAndClosedByDeadline(): void + { + $schedule = new ConferenceSchedule([ + self::entry('open', ['cfp'], '2020-06-01'), + self::entry('closed', ['cfp'], '2020-05-31'), + self::entry('undated', ['cfp'], null), + ], '2020-06-01'); + + // Today counts as open; a CFP with no deadline can't be confirmed open, so it is closed. + self::assertSame(['open'], self::ids($schedule->openCallsForPapers)); + // Closed is ordered most-recently-closed first, with the undated CFP last. + self::assertSame(['closed', 'undated'], self::ids($schedule->closedCallsForPapers)); + self::assertSame([], $schedule->upcomingConferences); + self::assertSame([], $schedule->pastConferences); + } + + public function testOpenCallsForPapersAreOrderedSoonestDeadlineFirst(): void + { + $schedule = new ConferenceSchedule([ + self::entry('later', ['cfp'], '2020-09-01'), + self::entry('sooner', ['cfp'], '2020-07-01'), + ], '2020-06-01'); + + self::assertSame(['sooner', 'later'], self::ids($schedule->openCallsForPapers)); + } + + public function testEntriesCarryingBothTermsAreTreatedAsConferences(): void + { + // A post that announces a conference and opens its CFP at once, in either term order. + $schedule = new ConferenceSchedule([ + self::entry('conf-first', ['conferences', 'cfp'], '2020-07-01'), + self::entry('cfp-first', ['cfp', 'conferences'], '2020-05-01'), + ], '2020-06-01'); + + self::assertSame(['conf-first'], self::ids($schedule->upcomingConferences)); + self::assertSame(['cfp-first'], self::ids($schedule->pastConferences)); + self::assertSame([], $schedule->openCallsForPapers); + self::assertSame([], $schedule->closedCallsForPapers); + } + + public function testConferencesWithoutAFinalTeaserDateFallIntoPast(): void + { + $schedule = new ConferenceSchedule([ + self::entry('undated', ['conferences'], null), + ], '2020-06-01'); + + self::assertSame(['undated'], self::ids($schedule->pastConferences)); + self::assertSame([], $schedule->upcomingConferences); + } + + /** + * @param list $terms + * + * @return array + */ + private static function entry(string $id, array $terms, ?string $finalTeaserDate): array + { + $entry = [ + 'id' => $id, + 'title' => $id, + 'category' => array_map(static fn (string $term): array => ['term' => $term, 'label' => $term], $terms), + ]; + + if ($finalTeaserDate !== null) { + $entry['finalTeaserDate'] = $finalTeaserDate; + } + + return $entry; + } + + /** + * @param list $entries + * + * @return list + */ + private static function ids(array $entries): array + { + return array_map(static fn (array $entry): string => (string) $entry['id'], $entries); + } +}