From 53914eac50e3e97db37cde289293fa4e60cd4b22 Mon Sep 17 00:00:00 2001 From: Dennis Petersmann Date: Tue, 20 Oct 2026 12:38:21 +0200 Subject: [PATCH] feat: show local time on profiles, opt-in Adds a show_local_time setting next to the timezone. When on and a timezone is set, the profile shows the current time there with its UTC offset, like GitHub does. It is off by default and clearing the timezone turns it off. --- KNOWLEDGE_BASE.md | 4 +- app/Actions/CreateExport.php | 1 + app/Actions/UpdateUserTimezone.php | 7 ++- .../Controllers/UserTimezoneController.php | 2 +- .../Requests/UpdateUserTimezoneRequest.php | 6 +++ app/Models/User.php | 3 ++ ...000_add_show_local_time_to_users_table.php | 17 +++++++ resources/js/components/local-time.tsx | 44 +++++++++++++++++ resources/js/components/timezone-picker.tsx | 48 +++++++++++++++++-- resources/js/lib/utils.ts | 18 +++++++ resources/js/pages/user/show.tsx | 4 ++ resources/js/types/auth.ts | 1 + .../Controllers/ExportControllerTest.php | 2 + .../Controllers/UserControllerTest.php | 15 ++++++ .../UserTimezoneControllerTest.php | 46 ++++++++++++++++++ tests/Unit/Actions/UpdateUserTimezoneTest.php | 26 ++++++++++ tests/Unit/AuthorHover.test.tsx | 1 + tests/Unit/CommitThread.test.tsx | 1 + tests/Unit/FollowState.test.tsx | 1 + tests/Unit/GuestFollow.test.tsx | 1 + tests/Unit/Models/UserTest.php | 1 + tests/Unit/NotificationInbox.test.tsx | 1 + tests/Unit/UtcOffset.test.ts | 25 ++++++++++ 23 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_10_20_120000_add_show_local_time_to_users_table.php create mode 100644 resources/js/components/local-time.tsx create mode 100644 tests/Unit/UtcOffset.test.ts diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index ba3e3c840..24482f93e 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -353,9 +353,9 @@ Settings → Appearance offers: These appearance choices are saved in the browser, not on the account, so each browser keeps its own. Settings → Profile lets you choose one of six lane colors (Orange, Green, Violet, Teal, Rose, Ochre), a custom color, or the automatic color used around your identity and activity graph. -Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. These /dev/null preferences are separate from your GitHub-managed profile information. +Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. A separate, off-by-default setting shows your current local time on your profile, next to your location and website; it only applies while a timezone is set, and clearing the timezone turns it off. These /dev/null preferences are separate from your GitHub-managed profile information. -Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane picker](resources/js/components/lane-picker.tsx), [timezone picker](resources/js/components/timezone-picker.tsx), [timestamp tooltip](resources/js/components/timestamp-tooltip.tsx). +Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane picker](resources/js/components/lane-picker.tsx), [timezone picker](resources/js/components/timezone-picker.tsx), [local time](resources/js/components/local-time.tsx), [timestamp tooltip](resources/js/components/timestamp-tooltip.tsx). ### Installing /dev/null as an app diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 2ae21603c..5b4f57520 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -50,6 +50,7 @@ public function handle(User $user): array 'has_sponsors_listing' => $user->has_sponsors_listing, 'preferred_lane' => $user->preferred_lane, 'timezone' => $user->timezone, + 'show_local_time' => $user->show_local_time, 'translation_language' => $user->translation_language, 'lane_color' => $user->lane_color, 'pasteis_mode' => $user->pasteis_mode, diff --git a/app/Actions/UpdateUserTimezone.php b/app/Actions/UpdateUserTimezone.php index 167eaa6f8..c66be990c 100644 --- a/app/Actions/UpdateUserTimezone.php +++ b/app/Actions/UpdateUserTimezone.php @@ -8,8 +8,11 @@ final readonly class UpdateUserTimezone { - public function handle(User $user, ?string $timezone): void + public function handle(User $user, ?string $timezone, bool $showLocalTime = false): void { - $user->update(['timezone' => $timezone]); + $user->update([ + 'timezone' => $timezone, + 'show_local_time' => $timezone !== null && $showLocalTime, + ]); } } diff --git a/app/Http/Controllers/UserTimezoneController.php b/app/Http/Controllers/UserTimezoneController.php index c62e58109..5edfe7c61 100644 --- a/app/Http/Controllers/UserTimezoneController.php +++ b/app/Http/Controllers/UserTimezoneController.php @@ -15,7 +15,7 @@ { public function update(UpdateUserTimezoneRequest $request, #[CurrentUser] User $user, UpdateUserTimezone $action): RedirectResponse { - $action->handle($user, $request->timezone()); + $action->handle($user, $request->timezone(), $request->showLocalTime()); Inertia::flash('toast', [ 'type' => 'success', diff --git a/app/Http/Requests/UpdateUserTimezoneRequest.php b/app/Http/Requests/UpdateUserTimezoneRequest.php index 9f4de87a5..a626e9020 100644 --- a/app/Http/Requests/UpdateUserTimezoneRequest.php +++ b/app/Http/Requests/UpdateUserTimezoneRequest.php @@ -20,6 +20,7 @@ public function rules(): array { return [ 'timezone' => ['present', 'nullable', 'string', 'timezone:all'], + 'show_local_time' => ['nullable', 'boolean'], ]; } @@ -27,4 +28,9 @@ public function timezone(): ?string { return $this->filled('timezone') ? $this->string('timezone')->value() : null; } + + public function showLocalTime(): bool + { + return $this->boolean('show_local_time'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index a66f3680e..ee06d6c94 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -46,6 +46,7 @@ * @property-read string|null $pinned_commit_id * @property-read string|null $translation_language * @property-read string|null $timezone + * @property-read bool $show_local_time * @property-read LivePlatform|null $live_platform * @property-read string|null $live_url * @property-read CarbonInterface|null $live_ends_at @@ -122,6 +123,7 @@ final class User extends Authenticatable protected $attributes = [ 'log' => Log::Following->value, 'translation_language' => null, + 'show_local_time' => false, 'pasteis_mode' => false, 'remotes_enabled' => true, 'remotes_from' => RemotesFrom::Following->value, @@ -171,6 +173,7 @@ public function casts(): array 'pasteis_given_on' => 'date', 'pinned_commit_id' => 'string', 'timezone' => 'string', + 'show_local_time' => 'boolean', 'translation_language' => 'encrypted', 'log' => Log::class, 'onboarded_at' => 'datetime', diff --git a/database/migrations/2026_10_20_120000_add_show_local_time_to_users_table.php b/database/migrations/2026_10_20_120000_add_show_local_time_to_users_table.php new file mode 100644 index 000000000..a282b83b5 --- /dev/null +++ b/database/migrations/2026_10_20_120000_add_show_local_time_to_users_table.php @@ -0,0 +1,17 @@ +boolean('show_local_time')->default(false); + }); + } +}; diff --git a/resources/js/components/local-time.tsx b/resources/js/components/local-time.tsx new file mode 100644 index 000000000..3cece5c3a --- /dev/null +++ b/resources/js/components/local-time.tsx @@ -0,0 +1,44 @@ +import { Clock } from 'lucide-react'; +import { useSyncExternalStore } from 'react'; +import { formatUtcOffset, formatZonedTime } from '@/lib/utils'; + +const MINUTE = 60_000; + +function subscribe(onChange: () => void): () => void { + const interval = setInterval(onChange, MINUTE); + + return () => clearInterval(interval); +} + +/** + * Snapshots are whole minutes, so the clock re-renders when the minute turns rather than on every read. + */ +function currentMinute(): number { + return Math.floor(Date.now() / MINUTE) * MINUTE; +} + +/** + * What time it is for someone who chose to share their timezone. It renders nothing on the server, where the + * current time is not the reader's. + */ +export function LocalTime({ timeZone }: { timeZone: string }) { + const minute = useSyncExternalStore(subscribe, currentMinute, () => null); + + if (minute === null) { + return null; + } + + const now = new Date(minute); + + return ( +
  • + + + {' '} + ({formatUtcOffset(now, timeZone)}) + +
  • + ); +} diff --git a/resources/js/components/timezone-picker.tsx b/resources/js/components/timezone-picker.tsx index b403de480..5a91e147c 100644 --- a/resources/js/components/timezone-picker.tsx +++ b/resources/js/components/timezone-picker.tsx @@ -14,6 +14,8 @@ import UserTimezoneController from '@/actions/App/Http/Controllers/UserTimezoneC import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; import { useUser } from '@/hooks/use-user'; import { browserTimeZone } from '@/lib/utils'; @@ -49,6 +51,7 @@ function matches(timeZone: TimeZone, query: string): boolean { export default function TimezonePicker() { const { user } = useUser(); const [selected, setSelected] = useState(user.timezone); + const [showLocalTime, setShowLocalTime] = useState(user.show_local_time); const [query, setQuery] = useState(''); const detected = useSyncExternalStore( subscribe, @@ -68,7 +71,7 @@ export default function TimezonePicker() {
    + + @@ -169,7 +180,10 @@ export default function TimezonePicker() { {selected !== null && (