From be097569ceca85df86868916dedb3367aa796522 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 3 Nov 2026 10:13:36 +0000 Subject: [PATCH 1/4] fix: match hot topics flame to logo orange --- resources/js/components/hot-topics.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/hot-topics.tsx b/resources/js/components/hot-topics.tsx index adbe7a6dc..3eab25bea 100644 --- a/resources/js/components/hot-topics.tsx +++ b/resources/js/components/hot-topics.tsx @@ -15,7 +15,7 @@ export function HotTopics() { id="aside-hot-topics" className="mb-2 flex items-center gap-2 text-sm font-semibold" > - + Hot topics
    From f12d66e01541c67efe4cf5313d5f372558784517 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 3 Nov 2026 10:20:47 +0000 Subject: [PATCH 2/4] feat: preview recaps below bold hot topic headlines --- KNOWLEDGE_BASE.md | 2 +- app/Http/Middleware/HandleInertiaRequests.php | 14 ++++-- app/Queries/HeadlinePageQuery.php | 15 +----- app/Queries/HeadlineSummaryQuery.php | 35 +++++++++++++ resources/js/components/chat/chat-dock.tsx | 9 +++- resources/js/components/hot-topics.tsx | 9 +++- resources/js/components/user-safety-menu.tsx | 1 + resources/js/lib/commit-mutation-options.ts | 1 + resources/js/pages/conversation/index.tsx | 16 +++++- resources/js/pages/safety/edit.tsx | 7 ++- resources/js/pages/user/unavailable.tsx | 9 +++- resources/js/types/global.d.ts | 2 +- tests/Unit/CommitMutationOptions.test.ts | 2 + tests/Unit/HotTopics.test.tsx | 19 ++++++- .../Middleware/HandleInertiaRequestsTest.php | 50 +++++++++++++++++-- 15 files changed, 157 insertions(+), 34 deletions(-) create mode 100644 app/Queries/HeadlineSummaryQuery.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index cb3bee278..dea98f62f 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -206,7 +206,7 @@ Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) Approved top-level commits about technology, current affairs or substantive real-world events can receive brief, shared subject headlines. Technical and developer topics carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the subject of the post it quotes. Replies, casual small talk and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and similar newly generated headlines share the first headline rather than creating duplicates. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface. -The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation. +The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**, with bold headlines and a truncated recap underneath when one is available. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation. Hot topics can also receive a short AI-written recap based on approved, available top-level commits from the past eight hours. An AI usefulness check skips topics without enough substance to summarize. Recaps aim to explain the underlying situation or event in one or two short sentences, rather than cataloguing individual posts. They are not independently verified reporting and can be reused for up to two hours. Only post text and the headline are sent to the AI providers, without account metadata. Removing a source commit, rejecting it, or deleting its author's account clears the associated recap. Signed-in members can open a headline to read its available recap above semantic search results from the past week. Expired recaps are omitted, and a recap is hidden if linked public posts include an author the viewer has muted or blocked. The results respect visibility, mutes and blocks too. diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index d6ebdad63..5cfdc8f19 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -15,6 +15,7 @@ use App\Queries\BioProfilesQuery; use App\Queries\ConversationsQuery; use App\Queries\DevelopersCountQuery; +use App\Queries\HeadlineSummaryQuery; use App\Queries\HotHeadlinesQuery; use App\Queries\LiveFollowingQuery; use App\Queries\NotificationGroupsQuery; @@ -128,13 +129,16 @@ public function share(Request $request): array 'showsWhoToFollow' => $showsWhoToFollow, ...$user instanceof User && $hasAside ? [ 'liveFollowing' => Inertia::once(fn (): Collection => resolve(LiveFollowingQuery::class)->get($user))->until(60), - 'hotTopics' => Inertia::once(fn (): array => array_map( - fn (array $headline): array => [ + 'hotTopics' => Inertia::once(function () use ($user): array { + $topics = resolve(HotHeadlinesQuery::class)->get($user); + $summaries = resolve(HeadlineSummaryQuery::class)->getMany(array_column($topics, 'id'), $user); + + return array_map(fn (array $headline): array => [ 'id' => $headline['id'], 'name' => $headline['name'], - ], - resolve(HotHeadlinesQuery::class)->get($user), - ))->until(60), + 'summary' => $summaries->get($headline['id']), + ], $topics); + })->until(60), ] : [], 'dimsLanes' => $request->cookie('lane_dimming') !== 'false', 'repliesPerPage' => RepliesPerPage::of($request)->value, diff --git a/app/Queries/HeadlinePageQuery.php b/app/Queries/HeadlinePageQuery.php index 1f080d4d7..0881f1614 100644 --- a/app/Queries/HeadlinePageQuery.php +++ b/app/Queries/HeadlinePageQuery.php @@ -12,7 +12,7 @@ final readonly class HeadlinePageQuery { - public function __construct(private CommitSearchQuery $search) + public function __construct(private CommitSearchQuery $search, private HeadlineSummaryQuery $summary) { // } @@ -24,18 +24,7 @@ public function get(Headline $headline, User $viewer): array { return [ 'name' => $headline->name, - 'summary' => function () use ($headline, $viewer): ?string { - if ($headline->summary === null || ! $headline->summary_created_at?->gt(now()->subHours(Headline::SUMMARY_HOURS))) { - return null; - } - - // Without stored provenance, check all linked posts rather than risk repeating a hidden author's words. - $hasHiddenSources = $headline->commits()->topLevel()->approved() - ->whereNotIn('commits.id', $headline->commits()->select('commits.id')->visibleTo($viewer)->withoutMutedAuthorsFor($viewer)) - ->exists(); - - return $hasHiddenSources ? null : $headline->summary; - }, + 'summary' => fn (): ?string => $this->summary->get($headline, $viewer), 'commits' => fn (): Collection => $this->search->get($headline->name, $viewer, since: now()->subWeek()), ]; } diff --git a/app/Queries/HeadlineSummaryQuery.php b/app/Queries/HeadlineSummaryQuery.php new file mode 100644 index 000000000..755f20d99 --- /dev/null +++ b/app/Queries/HeadlineSummaryQuery.php @@ -0,0 +1,35 @@ +getMany([$headline->id], $viewer)->get($headline->id); + } + + /** + * @param list $headlineIds + * @return Collection + */ + public function getMany(array $headlineIds, User $viewer): Collection + { + // Without stored provenance, check all linked posts rather than risk repeating a hidden author's words. + return Headline::query()->whereKey($headlineIds) + ->whereNotNull('summary') + ->where('summary_created_at', '>', now()->subHours(Headline::SUMMARY_HOURS)) + ->whereDoesntHave('commits', fn (Builder $query): Builder => $query->topLevel()->approved() + ->whereNotIn('commits.id', Commit::query()->select('commits.id')->visibleTo($viewer)->withoutMutedAuthorsFor($viewer))) + ->get() + ->mapWithKeys(fn (Headline $headline): array => [$headline->id => $headline->summary]); + } +} diff --git a/resources/js/components/chat/chat-dock.tsx b/resources/js/components/chat/chat-dock.tsx index 2649618e7..53aef369c 100644 --- a/resources/js/components/chat/chat-dock.tsx +++ b/resources/js/components/chat/chat-dock.tsx @@ -75,9 +75,14 @@ function DockedChat({ user }: { user: User }) { const leave = () => updateChatDock({ conversation: null }); + const blocked = () => + router.reload({ + onSuccess: () => router.reload({ only: ['hotTopics'] }), + }); + const leaveBlocked = () => { leave(); - router.reload(); + blocked(); }; const toggle = () => updateChatDock({ isExpanded: !dock.isExpanded }); @@ -246,7 +251,7 @@ function DockedChat({ user }: { user: User }) { router.reload()} + onBlocked={blocked} isCompact empty={

    diff --git a/resources/js/components/hot-topics.tsx b/resources/js/components/hot-topics.tsx index 3eab25bea..70c233f3d 100644 --- a/resources/js/components/hot-topics.tsx +++ b/resources/js/components/hot-topics.tsx @@ -23,12 +23,17 @@ export function HotTopics() {

  • - + {topic.name} + {topic.summary !== null && ( + + {topic.summary} + + )}
  • ))} diff --git a/resources/js/components/user-safety-menu.tsx b/resources/js/components/user-safety-menu.tsx index 0880e42ee..57c01988c 100644 --- a/resources/js/components/user-safety-menu.tsx +++ b/resources/js/components/user-safety-menu.tsx @@ -85,6 +85,7 @@ export function UserSafetyMenu({ options.onSuccess?.(page); router.flushAll(); + router.reload({ only: ['hotTopics'] }); }, }); }; diff --git a/resources/js/lib/commit-mutation-options.ts b/resources/js/lib/commit-mutation-options.ts index f0eec3d95..3dad48fdd 100644 --- a/resources/js/lib/commit-mutation-options.ts +++ b/resources/js/lib/commit-mutation-options.ts @@ -17,6 +17,7 @@ export function commitMutationOptions(component: string) { 'ancestors', 'continuation', 'hasUnmoderatedCommits', + 'hotTopics', ...(component === 'headline/show' ? ['summary'] : []), ...(component === 'moderation/show' ? ['commits'] : []), ], diff --git a/resources/js/pages/conversation/index.tsx b/resources/js/pages/conversation/index.tsx index fe29d6d19..c251f0d67 100644 --- a/resources/js/pages/conversation/index.tsx +++ b/resources/js/pages/conversation/index.tsx @@ -79,6 +79,12 @@ export default function Index({ const message = (person: User) => open({ kind: 'direct', partner: person }); const leave = () => visit(ConversationController.index()); + const blocked = () => + router.visit(ConversationController.index(), { + preserveScroll: true, + preserveState: true, + onSuccess: () => router.reload({ only: ['hotTopics'] }), + }); return ( <> @@ -169,8 +175,10 @@ export default function Index({ user={user} activeKey={target === null ? null : targetKey(target)} onOpen={open} - onLeaveActive={leave} - onBlocked={() => router.reload({ only: ['inbox'] })} + onLeaveActive={blocked} + onBlocked={() => + router.reload({ only: ['inbox', 'hotTopics'] }) + } empty={ setIsPicking(true)} /> } @@ -188,6 +196,7 @@ export default function Index({ partner={target.partner} user={user} onLeave={leave} + onBlocked={blocked} /> ) : (
    @@ -252,10 +261,12 @@ function ThreadHeader({ partner, user, onLeave, + onBlocked, }: { partner: User; user: User; onLeave: () => void; + onBlocked: () => void; }) { const { threads } = useChat(); @@ -294,6 +305,7 @@ function ThreadHeader({ user={user} isArchived={threads[partner.handle]?.is_archived ?? false} onLeave={onLeave} + onBlocked={onBlocked} className="ml-auto size-9" />
    diff --git a/resources/js/pages/safety/edit.tsx b/resources/js/pages/safety/edit.tsx index 67405010e..f463ee336 100644 --- a/resources/js/pages/safety/edit.tsx +++ b/resources/js/pages/safety/edit.tsx @@ -86,7 +86,12 @@ function PeopleList({ ) .visit(action.href, { preserveScroll: true, - onSuccess: clearMentionSuggestionsCache, + onSuccess: () => { + clearMentionSuggestionsCache(); + router.reload({ + only: ['hotTopics'], + }); + }, }) } aria-label={`${action.label} @${person.handle}`} diff --git a/resources/js/pages/user/unavailable.tsx b/resources/js/pages/user/unavailable.tsx index 1f04a4bb1..0cf1ae01d 100644 --- a/resources/js/pages/user/unavailable.tsx +++ b/resources/js/pages/user/unavailable.tsx @@ -1,4 +1,4 @@ -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router } from '@inertiajs/react'; import { Ban } from 'lucide-react'; import UserBlockController from '@/actions/App/Http/Controllers/UserBlockController'; import { EmptyFeed } from '@/components/commit-feed'; @@ -45,7 +45,12 @@ export default function Unavailable({ blockedUser, )} as="button" - onSuccess={clearMentionSuggestionsCache} + onSuccess={() => { + clearMentionSuggestionsCache(); + router.reload({ + only: ['hotTopics'], + }); + }} data-test="unblock-button" > diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index c8c9fce81..f8c1fd689 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -39,7 +39,7 @@ declare module '@inertiajs/core' { now: number; suggestedUsers?: SuggestedUser[]; liveFollowing?: User[]; - hotTopics?: { id: string; name: string }[]; + hotTopics?: { id: string; name: string; summary: string | null }[]; [key: string]: unknown; }; } diff --git a/tests/Unit/CommitMutationOptions.test.ts b/tests/Unit/CommitMutationOptions.test.ts index f7615de5d..d4cc35a80 100644 --- a/tests/Unit/CommitMutationOptions.test.ts +++ b/tests/Unit/CommitMutationOptions.test.ts @@ -28,9 +28,11 @@ const tombstone: DeletedCommit = { void test('refreshes the recap after headline mutations without rerunning search or resetting loaded results', () => { const options = commitMutationOptions('headline/show'); assert.ok(options.only.includes('summary')); + assert.ok(options.only.includes('hotTopics')); assert.ok(!options.only.includes('commits')); assert.deepEqual(options.reset, []); assert.ok(!commitMutationOptions('log/show').only.includes('summary')); + assert.ok(commitMutationOptions('log/show').only.includes('hotTopics')); }); void test('replaces every loaded quote embed with a tombstone without changing row order or unrelated rows', () => { diff --git a/tests/Unit/HotTopics.test.tsx b/tests/Unit/HotTopics.test.tsx index f54d57fbe..c5828d2fc 100644 --- a/tests/Unit/HotTopics.test.tsx +++ b/tests/Unit/HotTopics.test.tsx @@ -5,7 +5,10 @@ import { renderToString } from 'react-dom/server'; import { test } from 'vite-plus/test'; import { HotTopics } from '../../resources/js/components/hot-topics'; -async function render(names: string[]): Promise { +async function render( + names: string[], + summary: string | null = null, +): Promise { const result = await createInertiaApp({ page: { component: 'test', @@ -14,6 +17,7 @@ async function render(names: string[]): Promise { hotTopics: names.map((name, index) => ({ id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, name, + summary, })), }, url: '/', @@ -47,3 +51,16 @@ test('shows topic links without counts or a window caption', async () => { test('omits the hot topics section when no topic qualifies', async () => { assert.doesNotMatch(await render([]), /
    { + const html = await render( + ['Database indexes'], + 'Compare indexing strategies.', + ); + + assert.match( + html, + /Database indexes<\/span>]*>Compare <b>indexing<\/b> strategies\.<\/span>/, + ); + assert.doesNotMatch(html, /indexing<\/b>/); +}); diff --git a/tests/Unit/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Middleware/HandleInertiaRequestsTest.php index 97a921e66..826c61c5c 100644 --- a/tests/Unit/Middleware/HandleInertiaRequestsTest.php +++ b/tests/Unit/Middleware/HandleInertiaRequestsTest.php @@ -394,7 +394,7 @@ $user = User::factory()->create(); $muted = User::factory()->create(); $user->muting()->attach($muted); - $topic = Headline::factory()->create(['name' => 'Database indexes']); + $topic = Headline::factory()->create(['name' => 'Database indexes', 'summary' => 'Developers compare indexing strategies.', 'summary_created_at' => now()]); $hidden = Headline::factory()->create(); Commit::factory()->hasAttached($topic)->create(); Commit::factory()->for($muted)->hasAttached($hidden)->create(); @@ -405,7 +405,7 @@ $this->actingAs($user)->withHeaders($headers)->get(route('starred.index')) ->assertOk() - ->assertJsonPath('props.hotTopics', [['id' => $topic->id, 'name' => 'Database indexes']]) + ->assertJsonPath('props.hotTopics', [['id' => $topic->id, 'name' => 'Database indexes', 'summary' => 'Developers compare indexing strategies.']]) ->assertJsonPath('onceProps.hotTopics.expiresAt', now()->addMinute()->getTimestampMs()); $this->withHeaders([...$headers, 'X-Inertia-Except-Once-Props' => 'hotTopics'])->get(route('starred.index')) @@ -420,11 +420,53 @@ 'X-Inertia-Partial-Component' => 'starred/index', 'X-Inertia-Partial-Data' => 'hotTopics', ])->get(route('starred.index'))->assertOk()->assertJsonPath('props.hotTopics', [ - ['id' => $next->id, 'name' => 'Accessible developer tools'], - ['id' => $topic->id, 'name' => 'Database indexes'], + ['id' => $next->id, 'name' => 'Accessible developer tools', 'summary' => null], + ['id' => $topic->id, 'name' => 'Database indexes', 'summary' => 'Developers compare indexing strategies.'], ]); }); +it('loads all five hot topic recaps in two queries regardless of the number of recaps', function (): void { + $viewer = User::factory()->create(); + $headlines = Headline::factory()->count(5)->create(['summary' => 'A fresh recap.', 'summary_created_at' => now()]); + foreach ($headlines as $headline) { + Commit::factory()->hasAttached($headline)->create(); + } + + $request = Request::create(route('starred.index'), 'GET'); + $request->setUserResolver(fn () => $viewer); + + $shared = resolve(HandleInertiaRequests::class)->share($request); + + DB::enableQueryLog(); + $topics = $shared['hotTopics'](); + + expect(array_column($topics, 'summary'))->toBe(array_fill(0, 5, 'A fresh recap.')) + ->and(DB::getQueryLog())->toHaveCount(2); +}); + +it('omits expired recaps from hot topics', function (): void { + $this->freezeSecond(); + $topic = Headline::factory()->create(['summary' => 'An expired recap.', 'summary_created_at' => now()->subHours(2)]); + Commit::factory()->hasAttached($topic)->create(); + + $this->actingAs(User::factory()->create())->get(route('starred.index')) + ->assertOk()->assertInertia(fn ($page) => $page + ->where('hotTopics.0.id', $topic->id)->where('hotTopics.0.summary', null)); +}); + +it('does not repeat hidden authors through sidebar recaps', function (string $relationship): void { + $viewer = User::factory()->create(); + $hidden = User::factory()->create(); + $viewer->{$relationship}()->attach($hidden); + $topic = Headline::factory()->create(['summary' => 'A recap with hidden contributions.', 'summary_created_at' => now()]); + Commit::factory()->for($hidden)->hasAttached($topic)->create(); + Commit::factory()->hasAttached($topic)->create(); + + $this->actingAs($viewer)->get(route('starred.index')) + ->assertOk()->assertInertia(fn ($page) => $page + ->where('hotTopics.0.id', $topic->id)->where('hotTopics.0.summary', null)); +})->with(['muting', 'blocking']); + it('keeps the live following list of the aside for a minute, unless a partial reload asks for it', function (): void { $this->freezeSecond(); $user = User::factory()->create(); From 52af28a85f0eca38fc8fcf859ad8bb85c20b0b72 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 3 Nov 2026 10:54:03 +0000 Subject: [PATCH 3/4] test: seed hot topic headlines and recaps --- database/seeders/DatabaseSeeder.php | 1 + database/seeders/HeadlineSeeder.php | 70 ++++++++++++++++++++++++++++ tests/Feature/DatabaseSeederTest.php | 54 +++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 database/seeders/HeadlineSeeder.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4f6f57e10..9f49cf06e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -64,6 +64,7 @@ public function run(): void )->create(); $this->seedOrganizations($localDeveloper, $users); + $this->call(HeadlineSeeder::class); $commits = Commit::query()->topLevel()->get(); diff --git a/database/seeders/HeadlineSeeder.php b/database/seeders/HeadlineSeeder.php new file mode 100644 index 000000000..31a454856 --- /dev/null +++ b/database/seeders/HeadlineSeeder.php @@ -0,0 +1,70 @@ +map(fn (int $index): User => User::query()->firstWhere('github_id', "headline-seed-{$index}") + ?? User::factory()->create(['github_id' => "headline-seed-{$index}"])); + $topics = [ + ['Database indexing', 'Developers compare composite and partial indexes to speed up queries without adding unnecessary write overhead.', [ + 'A composite index on tenant_id and created_at took our activity query from 240 ms to 12 ms. The query plan finally stopped scanning the whole table.', + 'Trying partial indexes for queued jobs in PostgreSQL. Indexing only the pending rows keeps the index small even as completed jobs pile up.', + ]], + ['Accessible developer tools', 'Keyboard navigation and screen-reader support are getting attention in editors, terminals and other developer tools.', [ + 'Shipped keyboard navigation for our command palette. Every action now works without a mouse, including nested menus and search results.', + 'Testing our terminal UI with a screen reader found three unlabeled buttons. Accessibility checks are going into the release checklist.', + ]], + ['Laravel community updates and upcoming releases', 'The community shares upgrade notes and practical examples of queues, typed configuration and concurrency.', [ + 'Our Laravel upgrade is ready. Typed configuration caught a missing setting before deploy, and the full test suite stayed green.', + 'Using Laravel concurrency for independent imports cut our dashboard refresh time in half. Writing up the trade-offs for the next meetup.', + ]], + ['Open-source maintenance', null, [ + 'Opened a good first issue for improving the installation guide. Small documentation fixes are a great way to start contributing.', + 'Set aside Friday afternoon for reviewing community pull requests. Keeping the review queue moving matters as much as shipping new features.', + ]], + ['Browser performance', 'Teams explore ways to avoid layout shifts, reduce JavaScript work and keep scrolling smooth on slower devices.', [ + 'Reserved the image dimensions in our feed and removed a layout shift on first load. The fix was two attributes, not another JavaScript observer.', + 'Profiling our scrolling list on a slower laptop exposed expensive work in every row. Moving that work out of render made the feed noticeably smoother.', + ]], + ]; + + foreach ($topics as [$name, $summary, $bodies]) { + $headline = Headline::query()->updateOrCreate(['name' => $name], [ + 'summary' => $summary, + 'summary_created_at' => $summary === null ? null : now(), + ]); + + foreach ($users as $index => $user) { + $attributes = [ + 'body' => $bodies[$index], + 'created_at' => now()->subMinutes(15 + $index * 30), + ]; + $commit = Commit::query()->whereBelongsTo($user)->firstWhere('body', $attributes['body']) + ?? Commit::factory()->for($user)->make($attributes); + $commit->fill($attributes)->save(); + $headline->commits()->syncWithoutDetaching($commit); + + $this->updateCommitEmbedding->handle($commit); + } + } + }); + } +} diff --git a/tests/Feature/DatabaseSeederTest.php b/tests/Feature/DatabaseSeederTest.php index 3a7324676..773d89cd7 100644 --- a/tests/Feature/DatabaseSeederTest.php +++ b/tests/Feature/DatabaseSeederTest.php @@ -5,14 +5,19 @@ use App\Enums\InboxTab; use App\Enums\OrganizationRole; use App\Models\Commit; +use App\Models\Headline; use App\Models\Notification; use App\Models\Organization; use App\Models\User; use App\Queries\ConversationsQuery; +use App\Queries\HeadlineSummaryQuery; +use App\Queries\HotHeadlinesQuery; use App\Queries\LogQueries\FollowingLogQuery; use App\Queries\NotificationGroupsQuery; use App\Queries\OrganizationMentionsQuery; use Database\Seeders\DatabaseSeeder; +use Database\Seeders\HeadlineSeeder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; it('gives the local developer people to follow and followers', function (): void { @@ -25,6 +30,55 @@ ->and($localDeveloper->followers()->count())->toBe(5); }); +it('gives the local developer five hot topics with linked discussions and available recaps', function (string $seeder): void { + $localDeveloper = User::factory()->create(['github_id' => 'local', 'handle' => 'local-dev']); + + $this->seed($seeder); + + $topics = resolve(HotHeadlinesQuery::class)->get($localDeveloper); + $headlines = Headline::query()->whereIn('id', array_column($topics, 'id'))->get(); + + expect($topics)->toHaveCount(5) + ->and(array_column($topics, 'name'))->toContain('Database indexing', 'Open-source maintenance') + ->and(array_column($topics, 'commits_count'))->toBe([2, 2, 2, 2, 2]) + ->and($headlines->filter(fn (Headline $headline): bool => resolve(HeadlineSummaryQuery::class)->get($headline, $localDeveloper) !== null))->toHaveCount(4); + + foreach ($headlines as $headline) { + $this->actingAs($localDeveloper)->get(route('headline.show', $headline)) + ->assertOk()->assertInertia(fn ($page) => $page + ->component('headline/show') + ->where('name', $headline->name) + ->where('commits', fn (Collection $commits): bool => collect($headline->commits->modelKeys())->diff($commits->pluck('id'))->isEmpty())); + } +})->with([DatabaseSeeder::class, HeadlineSeeder::class]); + +it('reuses headline fixtures and refreshes their recaps when the standalone seeder runs again', function (string $seeder): void { + $this->seed($seeder); + $headlines = Headline::query()->orderBy('name')->pluck('id', 'name')->all(); + $commits = Commit::query()->orderBy('id')->pluck('id')->all(); + $users = User::query()->count(); + Headline::query()->update(['summary' => 'An outdated recap.', 'summary_created_at' => now()->subDay()]); + + $this->travel(10)->hours(); + $this->seed(HeadlineSeeder::class); + + expect(Headline::query()->orderBy('name')->pluck('id', 'name')->all())->toBe($headlines) + ->and(Headline::query()->withCount('commits')->get()->pluck('commits_count')->all())->toBe([2, 2, 2, 2, 2]) + ->and(Commit::query()->orderBy('id')->pluck('id')->all())->toBe($commits) + ->and(resolve(HotHeadlinesQuery::class)->get())->toHaveCount(5); + $this->assertDatabaseCount('users', $users); + $this->assertDatabaseHas('headlines', [ + 'name' => 'Database indexing', + 'summary' => 'Developers compare composite and partial indexes to speed up queries without adding unnecessary write overhead.', + 'summary_created_at' => now(), + ]); + $this->assertDatabaseHas('headlines', [ + 'name' => 'Open-source maintenance', + 'summary' => null, + 'summary_created_at' => null, + ]); +})->with([DatabaseSeeder::class, HeadlineSeeder::class]); + it('reuses an existing local developer', function (): void { $localDeveloper = User::factory()->create(['github_id' => 'local']); From 9e8876528eac9fce2190d2edf803757ef06b9eb3 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 3 Nov 2026 10:54:03 +0000 Subject: [PATCH 4/4] feat: flicker the hot topics flame every five seconds --- resources/css/app.css | 21 +++++++++++++++++++++ resources/js/components/hot-topics.tsx | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/resources/css/app.css b/resources/css/app.css index 5faf09245..f3445529b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -268,6 +268,27 @@ both; --animate-star-pop: star-pop 450ms cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + --animate-flame-flicker: flame-flicker 5s ease-in-out infinite; + + @keyframes flame-flicker { + 0%, + 80%, + 100% { + transform: scale(1) skewX(0deg); + } + 84% { + transform: scale(0.94, 1.08) skewX(-3deg); + } + 88% { + transform: scale(1.04, 0.96) skewX(2deg); + } + 92% { + transform: scale(0.96, 1.05) skewX(-2deg); + } + 96% { + transform: scale(1.02, 0.98) skewX(1deg); + } + } @keyframes star-pop { 0% { diff --git a/resources/js/components/hot-topics.tsx b/resources/js/components/hot-topics.tsx index 70c233f3d..cef4156c5 100644 --- a/resources/js/components/hot-topics.tsx +++ b/resources/js/components/hot-topics.tsx @@ -15,7 +15,10 @@ export function HotTopics() { id="aside-hot-topics" className="mb-2 flex items-center gap-2 text-sm font-semibold" > - + Hot topics