From 03d484ad72f05ca9c82502082b6994515f097522 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 30 Oct 2026 19:17:15 +0100 Subject: [PATCH 1/2] feat: include the days a person was seen in their data export --- KNOWLEDGE_BASE.md | 5 +++-- PRODUCT_METRICS.md | 2 +- app/Actions/CreateExport.php | 2 ++ ...10_30_120000_create_user_active_days_table.php | 4 ++-- .../Feature/Controllers/ExportControllerTest.php | 15 +++++++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3f90a508a..890cd854a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -592,7 +592,8 @@ Sources: [issue command UI](resources/js/components/issue-command.tsx), [issue v Choose **Download JSON** in Settings → Profile to export your account data. The export includes: -- profile details and preferences, including live-stream details, translation language, the last day you used /dev/null, and when you accepted the commit rules; +- profile details and preferences, including live-stream details, translation language, and when you accepted the commit rules; +- the days you used /dev/null (dates, not times), oldest first, and the last of them; - your commits and replies (including pending, rejected, and deleted entries still stored), their cached translations, polls, and your votes; - stars, followers and people you follow, mutes, blocks, and dismissed **Not interested** suggestions; - messages you sent, including links to your voice notes, and notifications; @@ -637,7 +638,7 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts]( The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. -To count active people and how many come back, /dev/null remembers the **days** (not the times) you were signed in and used the site or the API, and the last of them; background refreshes do not count. These days are never shown to other people and are deleted with your account; your export includes the last of them. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in (unless the browser is driven by a program, like a scraper's), a `signin-click` event when you tap to sign in, a `signup` event right after an account is created, and `join-link-sent`, `join-link-visit` and `join-link-signup` when a guest asks for a link by email, opens it, and signs up from it, with nothing that identifies you. +To count active people and how many come back, /dev/null remembers the **days** (not the times) you were signed in and used the site or the API, and the last of them; background refreshes do not count. These days are never shown to other people, are included in your export, and are deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in (unless the browser is driven by a program, like a scraper's), a `signin-click` event when you tap to sign in, a `signup` event right after an account is created, and `join-link-sent`, `join-link-visit` and `join-link-signup` when a guest asks for a link by email, opens it, and signs up from it, with nothing that identifies you. Sources: [last seen tracking](app/Http/Middleware/UpdateLastSeenAt.php), [active days](app/Actions/UpdateUserLastSeenAt.php), [Fathom events](resources/js/lib/analytics.ts), [public page routes](routes/web.php), [About](resources/js/pages/about/show.tsx), [contributors](app/Queries/ContributorsQuery.php), [privacy policy](resources/js/pages/privacy/show.tsx), [terms](resources/js/pages/terms/show.tsx). diff --git a/PRODUCT_METRICS.md b/PRODUCT_METRICS.md index 1718fd009..70f5e2324 100644 --- a/PRODUCT_METRICS.md +++ b/PRODUCT_METRICS.md @@ -20,7 +20,7 @@ Each answers the three questions of the [data guidelines](.ai/guidelines/data.bl | Data | Where | Why the feature needs it | Who sees it | When it is deleted | |---|---|---|---|---| | The last day a person was seen (a date, no time) | `users.last_seen_at`, hidden | Retention and active users can't be derived from anything else: reading alone leaves no trace. Written at most once a day, without touching `updated_at`. | Admins, only as aggregates on the Growth page; the person in their export | With the account | -| Each day a person was seen (dates, no time) | `user_active_days` (`user_id`, `seen_at`) | Retention by signup cohort, day by day (active on day 1, 7, 14), comparable with public benchmarks: `last_seen_at` only keeps the latest day, and `active_user_counts` has no user ids. Written with `last_seen_at`, the first time each person is seen in a day. | Admins, only as aggregates. Not in the person's export, which has `last_seen_at` | With the account | +| Each day a person was seen (dates, no time) | `user_active_days` (`user_id`, `seen_at`) | Retention by signup cohort, day by day (active on day 1, 7, 14), comparable with public benchmarks: `last_seen_at` only keeps the latest day, and `active_user_counts` has no user ids. Written with `last_seen_at`, the first time each person is seen in a day. | Admins, only as aggregates; the person in their export (`active_days`) | With the account | | The number of people seen each day | `active_user_counts` (`seen_at`, `count`), no user ids | The daily active users history: `last_seen_at` only keeps each person's latest day, so past days can't be recomputed. Incremented the first time each person is seen in a day, so the count is exact. | Admins, on the Growth page | Never: it is an anonymous count | | Three anonymous events, `guest-visit`, `signin-click` and `signup` | Fathom | The conversion rate, counted on both sides by the same tool, and where in between visitors give up. No user id is sent. | Admins, through Fathom | Per Fathom's retention | | Three anonymous events, `join-link-sent`, `join-link-visit` and `join-link-signup` | Fathom | Whether the link a guest on a phone can ask for by email brings people back: sent when the email is asked for, when the page opens from its link (`#join-link`, then removed from the address), and when that tab then signs up, next to `signup`. Signing up leaves for GitHub, so Fathom sees the signup as a new visit; the tab remembers the link in its session storage instead. Someone who opens the link in one browser and signs up in another counts as any other signup, so it's a floor. | Admins, through Fathom | Per Fathom's retention | diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 29883260a..9a13b1e1f 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -37,6 +37,7 @@ * schema_version: int, * exported_at: string, * profile: array, + * active_days: array, * commits: Generator>, * polls: Generator, * poll_votes: Generator, @@ -105,6 +106,7 @@ public function handle(User $user): array 'created_at' => $user->created_at->toIso8601String(), 'updated_at' => $user->updated_at->toIso8601String(), ], + 'active_days' => DB::table('user_active_days')->where('user_id', $user->id)->oldest('seen_at')->pluck('seen_at')->all(), 'commits' => $this->commits($user), 'polls' => $this->polls($user), 'poll_votes' => $this->pollVotes($user), diff --git a/database/migrations/2026_10_30_120000_create_user_active_days_table.php b/database/migrations/2026_10_30_120000_create_user_active_days_table.php index 8fe069ea6..7e9521900 100644 --- a/database/migrations/2026_10_30_120000_create_user_active_days_table.php +++ b/database/migrations/2026_10_30_120000_create_user_active_days_table.php @@ -12,8 +12,8 @@ /** * Each day a person was seen, a date with no time, so retention can be read day by day for each signup cohort: * last_seen_at only keeps the latest day, and active_user_counts has no user ids. Only admins see it, as - * aggregates. DeleteUser erases the rows with the account. Everyone's last day is copied in: people already seen - * today aren't recorded again until tomorrow, so today would be missing for them. + * aggregates, and the person in their export. DeleteUser erases the rows with the account. Everyone's last day is + * copied in: people already seen today aren't recorded again until tomorrow, so today would be missing for them. */ public function up(): void { diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index f20ddf2f5..12325e7d0 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -126,6 +126,7 @@ 'created_at' => $user->created_at->toIso8601String(), 'updated_at' => $user->updated_at->toIso8601String(), ], + 'active_days' => [], 'commits' => [], 'polls' => [], 'poll_votes' => [], @@ -464,6 +465,20 @@ ]); }); +it('exports the days the user was seen, oldest first, without the days of others', function (): void { + $user = User::factory()->create(); + DB::table('user_active_days')->insert([ + ['user_id' => $user->id, 'seen_at' => '2026-10-21'], + ['user_id' => $user->id, 'seen_at' => '2026-10-19'], + ['user_id' => User::factory()->create()->id, 'seen_at' => '2026-10-20'], + ]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['active_days'])->toBe(['2026-10-19', '2026-10-21']); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]); From e8644bf3fca9db4ebf489c4bed2126cf342cf684 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Fri, 30 Oct 2026 19:39:25 +0100 Subject: [PATCH 2/2] fix: read the active days while the export streams, so an export that is the first visit of the day includes it --- app/Actions/CreateExport.php | 20 +++++++++++++++++-- .../Controllers/ExportControllerTest.php | 13 +++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 9a13b1e1f..1ee99a21c 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -37,7 +37,7 @@ * schema_version: int, * exported_at: string, * profile: array, - * active_days: array, + * active_days: Generator, * commits: Generator>, * polls: Generator, * poll_votes: Generator, @@ -106,7 +106,7 @@ public function handle(User $user): array 'created_at' => $user->created_at->toIso8601String(), 'updated_at' => $user->updated_at->toIso8601String(), ], - 'active_days' => DB::table('user_active_days')->where('user_id', $user->id)->oldest('seen_at')->pluck('seen_at')->all(), + 'active_days' => $this->activeDays($user), 'commits' => $this->commits($user), 'polls' => $this->polls($user), 'poll_votes' => $this->pollVotes($user), @@ -244,6 +244,22 @@ private function pollVotes(User $user): Generator } } + /** + * The days the user was seen, oldest first. They are read while the export streams, after the request asking for it + * was recorded, so an export that is the first visit of the day includes that day. + * + * @return Generator + */ + private function activeDays(User $user): Generator + { + /** @var LazyCollection $days */ + $days = DB::table('user_active_days')->where('user_id', $user->id)->oldest('seen_at')->cursor(); + + foreach ($days as $day) { + yield $day->seen_at; + } + } + /** * @return Generator */ diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 12325e7d0..409a08249 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -466,7 +466,7 @@ }); it('exports the days the user was seen, oldest first, without the days of others', function (): void { - $user = User::factory()->create(); + $user = User::factory()->create(['last_seen_at' => now()]); DB::table('user_active_days')->insert([ ['user_id' => $user->id, 'seen_at' => '2026-10-21'], ['user_id' => $user->id, 'seen_at' => '2026-10-19'], @@ -479,6 +479,17 @@ expect($data['active_days'])->toBe(['2026-10-19', '2026-10-21']); }); +it('exports today when the export is the first visit of the day', function (): void { + $this->travelTo(now()->setDate(2026, 10, 30)->setTime(12, 0)); + $user = User::factory()->create(['last_seen_at' => '2026-10-29']); + DB::table('user_active_days')->insert(['user_id' => $user->id, 'seen_at' => '2026-10-29']); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['active_days'])->toBe(['2026-10-29', '2026-10-30']); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]);