Author: Matt Stauffer (mattstauffer)
Committer: Derick Rethans (derickr)
Date: 2026-11-12T14:28:28Z
Commit: Split conferences and CFPs · php/web-php@a13511e · GitHub
Raw diff: https://github.com/php/web-php/commit/a13511e2beccef54656fb2505add4f2410a238b8.diff
Split conferences and CFPs
On the /conferences/ page, split out views for conferences (for attendees) and CFPs (for speakers)
Changed paths:
A src/News/ConferenceSchedule.php
A tests/Unit/News/ConferenceScheduleTest.php
M public/conferences/index.php
M public/styles/home.css
M src/News/Entry.php
Diff:
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 @@
<?php
+use phpweb\News\ConferenceSchedule;
use phpweb\News\NewsHandler;
$_SERVER['BASE_PAGE'] = 'conferences/index.php';
@@ -14,33 +15,141 @@
'css' => ['home.css'],
]);
-$content = "<div class='home-content'>";
-$panels = '<p class="prepend"><a href="PHP: conferences to see your conference appear here?</a></p>';
+$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 = '<nav class="conference-views" aria-label="Conference views">';
+foreach ($viewLinks as $key => $viewLink) {
+ $isActive = $key === $view;
+ $viewSwitcher .= sprintf(
+ '<a class="conference-view%s" href="%s"%s>%s</a>',
+ $isActive ? ' is-active' : '',
+ $viewLink['href'],
+ $isActive ? ' aria-current="page"' : '',
+ $viewLink['label'],
+ );
+}
+$viewSwitcher .= '</nav>';
-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 .= '<div class="newsentry">';
- $content .= '<h3 class="newstitle title"><a href="' . $MYSITE . $link . '" id="' . $id . '">' . $entry["title"] . '</a></h3>';
- $content .= '<div class="newsimage">';
+
+ $html = '<div class="newsentry">';
+ $html .= '<h3 class="newstitle title"><a href="' . $MYSITE . $link . '" id="' . $id . '">' . $entry["title"] . '</a></h3>';
+
+ 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 .= '<p class="newsdate">' . $label . ' <time datetime="' . $entry["finalTeaserDate"] . '">' . date_format(date_create($entry["finalTeaserDate"]), 'd M Y') . '</time></p>';
+ }
+
+ $html .= '<div class="newsimage">';
if (isset($entry["newsImage"])) {
- $content .= sprintf('<a href="%s"><img src="/images/news/%s"></a>', $entry["newsImage"]["link"], $entry["newsImage"]["content"]);
+ $html .= sprintf('<a href="%s"><img src="/images/news/%s"></a>', $entry["newsImage"]["link"], $entry["newsImage"]["content"]);
}
- $content .= '</div>';
- $content .= '<div class="newscontent">';
- $content .= $entry["content"];
- $content .= '</div>';
- $content .= '</div>';
+ $html .= '</div>';
+ $html .= '<div class="newscontent">';
+ $html .= $entry["content"];
+ $html .= '</div>';
+ $html .= '</div>';
+
+ return $html;
+};
+
+$content = "<div class='home-content'>";
+$content .= $viewSwitcher;
+
+if ($view === 'cfps') {
+ $content .= '<h2>Open calls for papers</h2>';
+
+ if ($schedule->openCallsForPapers === []) {
+ $content .= '<p>No open calls for papers right now.</p>';
+ } else {
+ foreach ($schedule->openCallsForPapers as $entry) {
+ $content .= $renderEntry($entry);
+ }
+ }
+
+ if ($schedule->closedCallsForPapers !== []) {
+ $content .= '<h2 class="conferences-divider">Closed calls for papers</h2>';
+
+ foreach ($schedule->closedCallsForPapers as $entry) {
+ $content .= $renderEntry($entry);
+ }
+ }
+} else {
+ $content .= '<h2>Upcoming conferences</h2>';
- $panels .= sprintf('<p class="panel"><a href="%s">%s</a></p>', $entry["newsImage"]["link"] ?? $link, $entry["title"]);
+ if ($schedule->upcomingConferences === []) {
+ $content .= '<p>No upcoming conferences right now.</p>';
+ } else {
+ foreach ($schedule->upcomingConferences as $entry) {
+ $content .= $renderEntry($entry);
+ }
+ }
+
+ if ($schedule->pastConferences !== []) {
+ $content .= '<h2 class="conferences-divider">Past conferences</h2>';
+
+ foreach ($schedule->pastConferences as $entry) {
+ $content .= $renderEntry($entry);
+ }
+ }
}
+
$content .= "</div>";
echo $content;
+// Build sidebar mirroring the active view: conferences grouped into upcoming/past, or CFPs into open/closed.
+$panels = '<p class="prepend"><a href="PHP: conferences to see your conference appear here?</a></p>';
+
+$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('<p class="panel"><a href="%s">%s</a></p>', $entry["newsImage"]["link"] ?? $link, $entry["title"]);
+ }
+
+ return $html;
+};
+
+if ($view === 'cfps') {
+ if ($schedule->openCallsForPapers !== []) {
+ $panels .= '<h3>Open</h3>' . $renderPanels($schedule->openCallsForPapers);
+ }
+
+ if ($schedule->closedCallsForPapers !== []) {
+ $panels .= '<h3>Closed</h3>' . $renderPanels($schedule->closedCallsForPapers);
+ }
+} else {
+ if ($schedule->upcomingConferences !== []) {
+ $panels .= '<h3>Upcoming</h3>' . $renderPanels($schedule->upcomingConferences);
+ }
+
+ if ($schedule->pastConferences !== []) {
+ $panels .= '<h3>Past</h3>' . $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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace phpweb\News;
+
+use function usort;
+
+/**
+ * Groups and orders conference/CFP news entries for the /conferences page and its CFP view.
+ *
+ * Conferences and calls for papers are separate, unlinked news entries, each with a single
+ * finalTeaserDate that means different things: for a conference it's the event's start date,
+ * for a CFP it's the submission deadline. They live on different pages:
+ *
+ * - The conferences view shows conferences only, split by start date into upcoming (soonest
+ * first) and past (most recent first). A conference with no finalTeaserDate falls into past.
+ * - The CFP view shows CFPs only, split by deadline into open (deadline today or later, soonest
+ * first) and closed (deadline passed, or no deadline, most recently closed first).
+ *
+ * finalTeaserDate nuance: for a conference it's start date, for a CFP it's end date
+ */
+final class ConferenceSchedule
+{
+ /** @var list<array> Conferences starting today or later, soonest first. */
+ public private(set) array $upcomingConferences = [];
+
+ /** @var list<array> Conferences already started (or undated), most recent first. */
+ public private(set) array $pastConferences = [];
+
+ /** @var list<array> CFPs whose submission deadline has not passed, soonest deadline first. */
+ public private(set) array $openCallsForPapers = [];
+
+ /** @var list<array> CFPs whose deadline has passed (or is unknown), most recently closed first. */
+ public private(set) array $closedCallsForPapers = [];
+
+ /**
+ * @param list<array> $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<array-key, mixed> $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<array-key, mixed> $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<array> $entries
+ *
+ * @return list<array>
+ */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace News;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use phpweb\News\ConferenceSchedule;
+
+#[CoversClass(ConferenceSchedule::class)]
+final class ConferenceScheduleTest extends TestCase
+{
+ public function testConferencesSplitByStartDateRelativeToToday(): void
+ {
+ $schedule = new ConferenceSchedule([
+ self::entry('future', ['conferences'], '2020-06-02'),
+ self::entry('today', ['conferences'], '2020-06-01'),
+ self::entry('past', ['conferences'], '2020-05-31'),
+ ], '2020-06-01');
+
+ // Today counts as upcoming; upcoming is ordered soonest first.
+ self::assertSame(['today', 'future'], self::ids($schedule->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<string> $terms
+ *
+ * @return array<string, mixed>
+ */
+ 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<array> $entries
+ *
+ * @return list<string>
+ */
+ private static function ids(array $entries): array
+ {
+ return array_map(static fn (array $entry): string => (string) $entry['id'], $entries);
+ }
+}