Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion PRODUCT_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
18 changes: 18 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* schema_version: int,
* exported_at: string,
* profile: array<string, bool|int|string|null>,
* active_days: Generator<int, string>,
* commits: Generator<int, array<string, mixed>>,
* polls: Generator<int, ExportPoll>,
* poll_votes: Generator<int, ExportPollVote>,
Expand Down Expand Up @@ -105,6 +106,7 @@ public function handle(User $user): array
'created_at' => $user->created_at->toIso8601String(),
'updated_at' => $user->updated_at->toIso8601String(),
],
'active_days' => $this->activeDays($user),
'commits' => $this->commits($user),
'polls' => $this->polls($user),
'poll_votes' => $this->pollVotes($user),
Expand Down Expand Up @@ -242,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<int, string>
*/
private function activeDays(User $user): Generator
{
/** @var LazyCollection<int, object{seen_at: string}> $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<int, string>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/Controllers/ExportControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
'created_at' => $user->created_at->toIso8601String(),
'updated_at' => $user->updated_at->toIso8601String(),
],
'active_days' => [],
'commits' => [],
'polls' => [],
'poll_votes' => [],
Expand Down Expand Up @@ -464,6 +465,31 @@
]);
});

it('exports the days the user was seen, oldest first, without the days of others', function (): void {
$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'],
['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 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()]);
Expand Down
Loading