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 .= '
';
- $content .= '
';
+
+ $html = '
';
+ $html .= '
';
+
+ 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);
+ }
+}