diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index be110dcc1..390960ac5 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -468,8 +468,12 @@ 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. +Date and time formats are also under Settings → Appearance. Choose month-first (`Oct 19`) or day-first (`19 Oct`) dates and a 12- or 24-hour clock independently. “Browser default” uses the browser's date order and clock format. Month and weekday names remain in English. These format preferences are private and saved on the account. They change display only, not stored timestamps, scheduling, or day grouping. + 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. +The profile clock uses the profile owner's timezone and the viewer's clock format. Date and time formats save separately from the profile timezone. + 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 41d90a5e3..8be0608d0 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -89,6 +89,8 @@ public function handle(User $user): array 'show_local_time' => $user->show_local_time, 'translation_language' => $user->translation_language, 'automatic_translation' => $user->automatic_translation ?? true, + 'date_format' => $user->date_format?->value, + 'time_format' => $user->time_format?->value, 'lane_color' => $user->lane_color, 'pasteis_mode' => $user->pasteis_mode, 'pasteis_received' => $user->pasteis_received, diff --git a/app/Actions/UpdateUserDateTime.php b/app/Actions/UpdateUserDateTime.php new file mode 100644 index 000000000..87181932f --- /dev/null +++ b/app/Actions/UpdateUserDateTime.php @@ -0,0 +1,20 @@ +update([ + 'date_format' => $dateFormat, + 'time_format' => $timeFormat, + ]); + } +} diff --git a/app/Dto/ReaderClock.php b/app/Dto/ReaderClock.php index 477ef6790..151052e41 100644 --- a/app/Dto/ReaderClock.php +++ b/app/Dto/ReaderClock.php @@ -4,6 +4,7 @@ namespace App\Dto; +use App\Enums\DateFormat; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\Request; use IntlTimeZone; @@ -21,6 +22,7 @@ public function __construct( public ?string $timeZone, public ?string $hourCycle, + public ?string $dateOrder, ) { // } @@ -29,6 +31,7 @@ public static function of(Request $request): self { $timeZone = $request->cookie('time_zone'); $hourCycle = $request->cookie('hour_cycle'); + $dateOrder = $request->cookie('date_order'); $isSystemTimeZone = false; if (is_string($timeZone)) { @@ -39,17 +42,19 @@ public static function of(Request $request): self return new self( is_string($timeZone) && $isSystemTimeZone ? $timeZone : null, is_string($hourCycle) && in_array($hourCycle, self::HOUR_CYCLES, true) ? $hourCycle : null, + is_string($dateOrder) ? DateFormat::tryFrom($dateOrder)?->value : null, ); } /** - * @return array{timeZone: string|null, hourCycle: string|null} + * @return array{timeZone: string|null, hourCycle: string|null, dateOrder: string|null} */ public function toArray(): array { return [ 'timeZone' => $this->timeZone, 'hourCycle' => $this->hourCycle, + 'dateOrder' => $this->dateOrder, ]; } } diff --git a/app/Enums/DateFormat.php b/app/Enums/DateFormat.php new file mode 100644 index 000000000..9a8871d9d --- /dev/null +++ b/app/Enums/DateFormat.php @@ -0,0 +1,11 @@ +handle( + $user, + $request->enum('date_format', DateFormat::class), + $request->enum('time_format', TimeFormat::class), + ); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Date and time preferences saved.', + ]); + + return back(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index aac05f251..b8f730f18 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -96,6 +96,10 @@ public function share(Request $request): array 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, 'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null, + 'dateTime' => fn (): array => [ + 'dateFormat' => $user?->date_format, + 'timeFormat' => $user?->time_format, + ], 'log' => fn (): ?Log => $request->user()?->log, 'unreadNotificationsCount' => fn (): int => $user instanceof User ? resolve(NotificationGroupsQuery::class)->unreadCount($user, ListNotificationsRequest::rememberedType($request, $user)) : 0, 'unreadMessagesCount' => fn (): int => $user?->remotes_enabled ? resolve(ConversationsQuery::class)->unreadCount($user) : 0, diff --git a/app/Http/Requests/UpdateUserDateTimeRequest.php b/app/Http/Requests/UpdateUserDateTimeRequest.php new file mode 100644 index 000000000..375969269 --- /dev/null +++ b/app/Http/Requests/UpdateUserDateTimeRequest.php @@ -0,0 +1,29 @@ +> + */ + public function rules(): array + { + return [ + 'date_format' => ['present', 'nullable', Rule::enum(DateFormat::class)], + 'time_format' => ['present', 'nullable', Rule::enum(TimeFormat::class)], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1dd40b624..77cc72c0c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,10 +7,12 @@ use App\Actions\CreateTomFriendship; use App\Actions\CreateWorldReply; use App\Dto\LiveStream; +use App\Enums\DateFormat; use App\Enums\LivePlatform; use App\Enums\Log; use App\Enums\ProfileType; use App\Enums\RemotesFrom; +use App\Enums\TimeFormat; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; @@ -54,6 +56,8 @@ * @property-read bool|null $automatic_translation * @property-read string|null $translation_language * @property-read string|null $timezone + * @property-read DateFormat|null $date_format + * @property-read TimeFormat|null $time_format * @property-read bool $show_local_time * @property-read LivePlatform|null $live_platform * @property-read string|null $live_url @@ -91,6 +95,8 @@ * @property-read int $followers_count */ #[Hidden([ + 'date_format', + 'time_format', 'profile', 'github_id', 'email', @@ -150,6 +156,8 @@ final class User extends Authenticatable * @var array */ protected $attributes = [ + 'date_format' => null, + 'time_format' => null, 'log' => Log::Following->value, 'translation_language' => null, 'show_local_time' => false, @@ -203,6 +211,8 @@ public function casts(): array 'show_local_time' => 'boolean', 'translation_language' => 'encrypted', 'automatic_translation' => 'boolean', + 'date_format' => DateFormat::class, + 'time_format' => TimeFormat::class, 'log' => Log::class, 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', diff --git a/bootstrap/app.php b/bootstrap/app.php index b2a43a1db..5e15a7ad7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -45,7 +45,7 @@ $middleware->trimStrings(except: ['sdp', 'signals.*.sdp']); $middleware->append(PreventFraming::class); - $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page', 'time_zone', 'hour_cycle']); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page', 'time_zone', 'hour_cycle', 'date_order']); // Approving the app is the one page a guest should leave for GitHub, then return to. Every other page // they open while signed out stays the log. Locally, sign-in is the modal on that log. diff --git a/database/migrations/2026_10_19_170000_add_date_and_time_formats_to_users_table.php b/database/migrations/2026_10_19_170000_add_date_and_time_formats_to_users_table.php new file mode 100644 index 000000000..89b3abcb8 --- /dev/null +++ b/database/migrations/2026_10_19_170000_add_date_and_time_formats_to_users_table.php @@ -0,0 +1,18 @@ +string('date_format')->nullable(); + $table->string('time_format')->nullable(); + }); + } +}; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 98e127f68..1f8ff5c59 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -3,7 +3,6 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import type { ComponentType } from 'react'; import { StrictMode } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; -import { TimeoutDialog } from '@/components/timeout-dialog'; import { Toaster } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { initializeTheme, setCookie } from '@/hooks/use-appearance'; @@ -40,13 +39,13 @@ void createInertiaApp({ // So the server renders the next pages with the reader's clock. setCookie('time_zone', clock.timeZone); setCookie('hour_cycle', clock.hourCycle); + setCookie('date_order', clock.dateOrder); const app = ( - ); @@ -57,7 +56,8 @@ void createInertiaApp({ if ( el.hasAttribute('data-server-rendered') && serverClock?.timeZone === clock.timeZone && - serverClock.hourCycle === clock.hourCycle + serverClock.hourCycle === clock.hourCycle && + serverClock.dateOrder === clock.dateOrder ) { setRenderedAt(props.initialPage.props.now); hydrateRoot(el, app); diff --git a/resources/js/components/activity-graph.tsx b/resources/js/components/activity-graph.tsx index f39df0d9a..8c0f282d8 100644 --- a/resources/js/components/activity-graph.tsx +++ b/resources/js/components/activity-graph.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, MouseEvent, ReactNode } from 'react'; import { useRef, useState } from 'react'; +import { useDateTime } from '@/hooks/use-date-time'; import { exactCountTitle, formatCount } from '@/lib/numbers'; import { clockTimeOf, cn, localDayKey } from '@/lib/utils'; import type { Activity, ActivityBucket, ActivityScale } from '@/types'; @@ -54,23 +55,6 @@ function monthOf(date: string): number { return utcDay(date).getUTCMonth(); } -function formatDay(date: string, withYear: boolean): string { - const day = utcDay(date); - const monthDay = `${monthNames[day.getUTCMonth()]} ${day.getUTCDate()}`; - - return withYear - ? `${monthDay}, ${day.getUTCFullYear()}` - : `${weekdayNames[day.getUTCDay()]}, ${monthDay}`; -} - -function formatTime(time: Date): string { - const { hour, minute } = clockTimeOf(time.toISOString()); - - return [hour, minute] - .map((part) => String(part).padStart(2, '0')) - .join(':'); -} - function count(total: number, [singular, plural]: ActivityNoun): string { return `${formatCount(total)} ${total === 1 ? singular : plural}`; } @@ -94,12 +78,13 @@ function describe( bucket: ActivityBucket, scale: ActivityScale, noun: ActivityNoun, + dateTime: ReturnType, ): string { const counted = bucket.count === 0 ? `No ${noun[1]}` : count(bucket.count, noun); if (scale !== 'day') { - return `${counted} on ${formatDay(bucket.date, scale === 'year')}`; + return `${counted} on ${dateTime.formatDate(utcDay(bucket.date), { timeZone: 'UTC', year: scale === 'year', weekday: scale !== 'year' })}`; } const start = new Date(bucket.date); @@ -108,7 +93,7 @@ function describe( localDayKey(start.toISOString()) === localDayKey(new Date().toISOString()); - return `${counted} between ${formatTime(start)} and ${formatTime(end)} ${isToday ? 'today' : 'yesterday'}`; + return `${counted} between ${dateTime.formatTime(start)} and ${dateTime.formatTime(end)} ${isToday ? 'today' : 'yesterday'}`; } function periodOf(bucket: ActivityBucket, scale: ActivityScale): string { @@ -120,6 +105,7 @@ function stripLabel( scale: ActivityScale, index: number, length: number, + dateTime: ReturnType, ): ReactNode { if (scale === 'week') { const day = utcDay(bucket.date); @@ -138,14 +124,14 @@ function stripLabel( const start = new Date(bucket.date); return clockTimeOf(bucket.date).hour % 6 === 0 && index < length - 2 - ? formatTime(start) + ? dateTime.formatTime(start) : null; } const day = utcDay(bucket.date); return day.getUTCDay() === 1 && index < length - 3 - ? `${monthNames[day.getUTCMonth()]} ${day.getUTCDate()}` + ? dateTime.formatDate(day, { timeZone: 'UTC', year: false }) : null; } @@ -233,6 +219,7 @@ export function ActivityCells({ onPeriodHover?: (period: string | null) => void; }) { const containerRef = useRef(null); + const dateTime = useDateTime(); const [hovered, setHovered] = useState(null); const { scale, buckets } = activity; @@ -311,7 +298,7 @@ export function ActivityCells({ data-tooltip style={{ left: hovered.left, top: hovered.top - 6 }} > - {describe(hovered.bucket, scale, noun)} + {describe(hovered.bucket, scale, noun, dateTime)} )} @@ -341,6 +328,7 @@ function Strip({ onEnter, onLeave, }: CellsProps & { scale: ActivityScale; summary: string }) { + const dateTime = useDateTime(); const columns = { gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))`, }; @@ -377,7 +365,13 @@ function Strip({ key={bucket.date} className="overflow-visible whitespace-nowrap" > - {stripLabel(bucket, scale, index, buckets.length)} + {stripLabel( + bucket, + scale, + index, + buckets.length, + dateTime, + )} ))} diff --git a/resources/js/components/changelog-dialog.tsx b/resources/js/components/changelog-dialog.tsx index 0b716a1c0..26ed80710 100644 --- a/resources/js/components/changelog-dialog.tsx +++ b/resources/js/components/changelog-dialog.tsx @@ -1,3 +1,4 @@ +import { usePage } from '@inertiajs/react'; import { ExternalLink } from 'lucide-react'; import { useRef, useState } from 'react'; import { flushSync } from 'react-dom'; @@ -15,6 +16,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; +import { useNow } from '@/hooks/use-now'; import type { Changelog, ChangelogDay, @@ -38,8 +40,14 @@ function DaySection({ onNavigate?: () => void; }) { const [isExpanded, setIsExpanded] = useState(false); + const now = useNow(); const firstRevealedLink = useRef(null); - const heading = changelogDayHeading(day.date, timeZone); + const heading = changelogDayHeading( + day.date, + timeZone, + now, + usePage().props.auth.dateTime, + ); const shown = day.pullRequests.slice(0, shownPerDay); const hidden = day.pullRequests.slice(shownPerDay); return ( diff --git a/resources/js/components/changelog-lane.tsx b/resources/js/components/changelog-lane.tsx index 71e97d710..37ba73317 100644 --- a/resources/js/components/changelog-lane.tsx +++ b/resources/js/components/changelog-lane.tsx @@ -2,14 +2,10 @@ import { Link } from '@inertiajs/react'; import { ArrowUpRight } from 'lucide-react'; import type { ReactNode, Ref } from 'react'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import type { ChangelogAuthor, ChangelogPullRequest } from '@/lib/changelog'; import { authorLane } from '@/lib/changelog'; -import { - cn, - formatFullDateTime, - formatRelativeTime, - laneColor, -} from '@/lib/utils'; +import { cn, laneColor } from '@/lib/utils'; import { show } from '@/routes/user'; export function changelogLaneColor(author: ChangelogAuthor): string { @@ -141,6 +137,7 @@ export function ChangelogLane({ timeZone?: string; onNavigate?: () => void; }) { + const { formatFullDateTime, formatRelativeTime } = useDateTime(); return (
    {pullRequests.map((pullRequest, index) => { diff --git a/resources/js/components/changelog-popover.tsx b/resources/js/components/changelog-popover.tsx index 7a71a16cb..4ce385c03 100644 --- a/resources/js/components/changelog-popover.tsx +++ b/resources/js/components/changelog-popover.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/popover'; import { Skeleton } from '@/components/ui/skeleton'; import { useChangelog } from '@/hooks/use-changelog'; +import { useNow } from '@/hooks/use-now'; import type { Changelog } from '@/lib/changelog'; import { changelogDayHeading, changelogDays } from '@/lib/changelog'; import { browserTimeZone } from '@/lib/utils'; @@ -28,6 +29,8 @@ function Summary({ timeZone: string; }) { const [day] = changelogDays(changelog.pullRequests ?? [], timeZone); + const preferences = usePage().props.auth.dateTime; + const now = useNow(); if (day === undefined) { return ( @@ -37,7 +40,7 @@ function Summary({ ); } - const heading = changelogDayHeading(day.date, timeZone); + const heading = changelogDayHeading(day.date, timeZone, now, preferences); const latest = (changelog.pullRequests ?? []).slice(0, latestCount); return ( diff --git a/resources/js/components/chat/conversation-list.tsx b/resources/js/components/chat/conversation-list.tsx index 7e7fe4d5f..02b961bf7 100644 --- a/resources/js/components/chat/conversation-list.tsx +++ b/resources/js/components/chat/conversation-list.tsx @@ -11,10 +11,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { UserAvatar } from '@/components/user-avatar'; import { WordmarkText } from '@/components/wordmark-text'; import { useChat } from '@/hooks/use-chat'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; import { compareConversations, summaryKey, targetOf } from '@/lib/chat'; import type { ChatTarget } from '@/lib/chat'; -import { cn, formatRelativeTime, laneColor } from '@/lib/utils'; +import { cn, laneColor } from '@/lib/utils'; import type { ConversationSummary, User } from '@/types'; export function ConversationList({ @@ -96,6 +97,7 @@ function ConversationRow({ onBlocked?: () => void; isCompact: boolean; }) { + const { formatRelativeTime } = useDateTime(); const now = useNow(); const [isMenuOpen, setIsMenuOpen] = useState(false); const { last_message: lastMessage, ahead } = conversation; diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 1da7d5bf7..316b05a74 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -61,6 +61,7 @@ import { import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; import { useChat, useThread } from '@/hooks/use-chat'; +import { useDateTime } from '@/hooks/use-date-time'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import { moveCaretToEnd, useStash } from '@/hooks/use-stash'; @@ -88,14 +89,7 @@ import type { ReactionUsage } from '@/lib/reactions'; import { isRemoteMuted, playPing } from '@/lib/remote-notifications'; import { restoreScrollAnchor, scrollAnchorOf } from '@/lib/scroll-anchor'; import type { ScrollAnchor } from '@/lib/scroll-anchor'; -import { - characterCount, - cn, - formatClockTime, - formatDayLabel, - laneColor, - localDayKey, -} from '@/lib/utils'; +import { characterCount, cn, laneColor, localDayKey } from '@/lib/utils'; import { show } from '@/routes/user'; import type { Message, @@ -169,6 +163,7 @@ export function ConversationThread({ onDropped?: () => void; }) { const thread = useThread(target); + const dateTime = useDateTime(); const chat = useChat(); const usage = chat.reactionUsage ?? noUsage; const isOrg = target.kind === 'org'; @@ -199,8 +194,9 @@ export function ConversationThread({ const head = thread?.head ?? 0; const headUntil = thread?.headUntil ?? 0; const items = useMemo( - () => itemsOf(messages, pending, head, headUntil, user, isOrg), - [messages, pending, head, headUntil, user, isOrg], + () => + itemsOf(messages, pending, head, headUntil, user, isOrg, dateTime), + [messages, pending, head, headUntil, user, isOrg, dateTime], ); const statusMessage = useMemo( () => statusMessageOf(messages, pending, user, isOrg), @@ -892,7 +888,9 @@ function itemsOf( headUntil: number, user: User, isOrg: boolean, + dateTime: ReturnType, ): Item[] { + const { formatDayLabel } = dateTime; const bubbles: { bubble: Bubble; at: string; @@ -1159,6 +1157,7 @@ function BubbleWithMeta({ onPin: ((message: Message, isPinned: boolean) => void) | null; }) { const [isReacting, setIsReacting] = useState(false); + const { formatTime: timeOf } = useDateTime(); const [isTrayOpen, setIsTrayOpen] = useState(false); const trayId = useId(); const reactButton = useRef(null); @@ -1442,7 +1441,7 @@ function BubbleWithMeta({ {status === 'pulled' && target.kind === 'direct' && pulls.readAt && - ` ${formatClockTime(pulls.readAt)}`} + ` ${timeOf(pulls.readAt)}`} {pulledBy.length > 0 && ' by'} {pulledBy.length > 0 && ( @@ -1454,7 +1453,7 @@ function BubbleWithMeta({ bubble.kind === 'message' && ( {showsStatus && pulledBy.length > 0 && ( <> diff --git a/resources/js/components/chat/pinned-messages.tsx b/resources/js/components/chat/pinned-messages.tsx index 0359bbfc1..86e3c98e9 100644 --- a/resources/js/components/chat/pinned-messages.tsx +++ b/resources/js/components/chat/pinned-messages.tsx @@ -49,12 +49,13 @@ import { } from '@/components/ui/popover'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { ChatRequestError, setMessagePinned, targetKey } from '@/lib/chat'; import type { ChatTarget } from '@/lib/chat'; import { withLeavingRows } from '@/lib/leaving-rows'; import type { ReactionUsage } from '@/lib/reactions'; import { readStorage, writeStorage } from '@/lib/storage'; -import { cn, formatClockTime, formatDayLabel, laneColor } from '@/lib/utils'; +import { cn, laneColor } from '@/lib/utils'; import { show } from '@/routes/user'; import type { Message, MessageReply, User } from '@/types'; @@ -553,6 +554,7 @@ function PinnedItem({ handedFocus: RefObject>; onGone: () => void; }) { + const { formatDayLabel, formatTime } = useDateTime(); const reactButton = useRef(null); const unpinButton = useRef(null); const trayId = useId(); @@ -669,7 +671,7 @@ function PinnedItem({ className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums" > {formatDayLabel(message.created_at)} ·{' '} - {formatClockTime(message.created_at)} + {formatTime(message.created_at)} {onReply !== null && ( diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 9634b1df0..6fae7df2f 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -49,6 +49,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { useCommitNotFound, useIsCommitDeleted, @@ -59,8 +60,6 @@ import { commitMutationOptions } from '@/lib/commit-mutation-options'; import { measureCommitSize } from '@/lib/commit-size'; import { cn, - formatFullDateTime, - formatRelativeTime, isDeleted, isWithinSeconds, laneColor, @@ -121,6 +120,7 @@ export function CommitCard({ props: { auth }, component, } = usePage(); + const { formatFullDateTime, formatRelativeTime } = useDateTime(); const now = useNow(); const hiddenLinkPreviewUrl = useContext(HiddenLinkPreviewContext); const { bodyRef, ...translation } = useCommitTranslation(commit); diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 572f3e1db..91e0e7316 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -5,13 +5,14 @@ import { Fragment, useEffect, useRef, useState } from 'react'; import { CommitCard } from '@/components/commit-card'; import { GraphCanvas } from '@/components/graph-canvas'; import { GraphRail, RailSegment } from '@/components/graph-rail'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; import { restoreFeedCommit, updateQuotedCommits, } from '@/lib/commit-mutation-options'; import { isDeliberateHover } from '@/lib/feed-hover'; -import { cn, formatDayLabel, laneColor, localDayKey } from '@/lib/utils'; +import { cn, laneColor, localDayKey } from '@/lib/utils'; import type { Commit, CursorPaginated } from '@/types'; const interludeAfter = 5; @@ -43,6 +44,7 @@ export function CommitFeed({ interlude?: (lane: string, isDimmed: boolean) => ReactNode; isBelowComposer?: boolean; }) { + const { formatDayLabel } = useDateTime(); const now = useNow(); const page = usePage(); const { dimsLanes } = page.props; diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 0095a5b84..c78ad7c99 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -66,6 +66,7 @@ import { } from '@/components/ui/tooltip'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { useCommitNotFound, useIsCommitDeleted, @@ -90,7 +91,6 @@ import { exactCountTitle, formatCount, formatExactCount } from '@/lib/numbers'; import { characterCount, cn, - formatRelativeTime, isDeleted, laneOf, laneStrokeStyle, @@ -1082,6 +1082,7 @@ function CommitReply({ onDeleted: () => void; }) { const { auth } = usePage().props; + const { formatRelativeTime } = useDateTime(); const now = useNow(); const hiddenLinkPreviewUrl = useContext(HiddenLinkPreviewContext); const { bodyRef, ...translation } = useCommitTranslation(reply); diff --git a/resources/js/components/date-time-settings.tsx b/resources/js/components/date-time-settings.tsx new file mode 100644 index 000000000..9fda87692 --- /dev/null +++ b/resources/js/components/date-time-settings.tsx @@ -0,0 +1,157 @@ +import { Form, router, usePage } from '@inertiajs/react'; +import { useState } from 'react'; +import UserDateTimeController from '@/actions/App/Http/Controllers/UserDateTimeController'; +import Heading from '@/components/heading'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useNow } from '@/hooks/use-now'; +import { createDateTime } from '@/lib/date-time'; +import type { DateTimePreferences } from '@/lib/date-time'; + +export default function DateTimeSettings() { + const saved = usePage().props.auth.dateTime; + const [dateFormat, setDateFormat] = useState(saved.dateFormat); + const [timeFormat, setTimeFormat] = useState(saved.timeFormat); + const now = useNow(); + + return ( +
    + + +
    ({ + ...data, + date_format: + data.date_format === 'browser' + ? null + : data.date_format, + time_format: + data.time_format === 'browser' + ? null + : data.time_format, + })} + onSuccess={() => router.flushAll()} + > + {({ processing, errors }) => ( + <> +
    +
    + + + +
    +
    + + + +
    +
    +

    + Choosing “Browser default” uses your browser’s date + order and clock format. +

    +

    + {createDateTime({ + dateFormat, + timeFormat, + }).formatFullDateTime(now.toISOString())} +

    + + + )} +
    +
    + ); +} diff --git a/resources/js/components/local-time.tsx b/resources/js/components/local-time.tsx index 16d907442..b2b071f07 100644 --- a/resources/js/components/local-time.tsx +++ b/resources/js/components/local-time.tsx @@ -1,6 +1,7 @@ import { Clock } from 'lucide-react'; import { useSyncExternalStore } from 'react'; -import { cn, formatUtcOffset, formatZonedTime } from '@/lib/utils'; +import { useDateTime } from '@/hooks/use-date-time'; +import { cn, formatUtcOffset } from '@/lib/utils'; const MINUTE = 60_000; @@ -30,6 +31,7 @@ export function LocalTime({ as?: 'li' | 'p'; className?: string; }) { + const { formatZonedTime } = useDateTime(); const minute = useSyncExternalStore(subscribe, currentMinute, () => null); if (minute === null) { diff --git a/resources/js/components/operations/charts.tsx b/resources/js/components/operations/charts.tsx index 4f43df2bd..deaf7821d 100644 --- a/resources/js/components/operations/charts.tsx +++ b/resources/js/components/operations/charts.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef, useState } from 'react'; import type { PointerEvent, ReactNode } from 'react'; +import { useDateTime } from '@/hooks/use-date-time'; import { formatCount } from '@/lib/numbers'; import { cn } from '@/lib/utils'; @@ -66,16 +67,6 @@ export function compactNumber(value: number): string { }).format(value); } -export function shortDate(date: string): string { - return new Date( - date.length === 10 ? `${date}T00:00:00Z` : date, - ).toLocaleDateString('en', { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }); -} - function Tooltip({ x, width, @@ -187,6 +178,7 @@ export function LineChart({ label: string; unit: string; }) { + const { formatDate } = useDateTime(); const [ref, width] = useWidth(); const [hovered, setHovered] = useState(null); const days: (LinePoint & @@ -306,7 +298,10 @@ export function LineChart({ } className="fill-muted-foreground font-mono text-[11px]" > - {shortDate(day.date)} + {formatDate(day.date, { + timeZone: 'UTC', + year: false, + })} ), )} @@ -381,7 +376,10 @@ export function LineChart({ {active && hovered !== null && (

    - {shortDate(active.date)} + {formatDate(active.date, { + timeZone: 'UTC', + year: false, + })} {active.isForecast && ( {' '} @@ -415,7 +413,12 @@ export function LineChart({ {days.map((day) => ( - {day.date} + {day.isForecast ? ' (expected)' : ''} {format(day.value)} diff --git a/resources/js/components/operations/events-log.tsx b/resources/js/components/operations/events-log.tsx index fca690a9e..eceae21c0 100644 --- a/resources/js/components/operations/events-log.tsx +++ b/resources/js/components/operations/events-log.tsx @@ -2,14 +2,9 @@ import { Link } from '@inertiajs/react'; import { ChevronRight } from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import UserController from '@/actions/App/Http/Controllers/UserController'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; -import { - cn, - dateLocale, - formatDayLabel, - localDayKey, - readerClock, -} from '@/lib/utils'; +import { cn, localDayKey } from '@/lib/utils'; import type { OperationsEvent, OperationsEventLevel } from '@/types'; export const LEVEL_DOTS: Record = { @@ -21,29 +16,6 @@ export const LEVEL_DOTS: Record = { const FRESH_MILLISECONDS = 2500; -/** An event's time on the reader's clock, which only shows once the page renders. */ -function formatEventTime( - at: string, - options: Intl.DateTimeFormatOptions, -): string { - return new Date(at).toLocaleString(dateLocale, { - ...options, - timeZone: readerClock().timeZone, - }); -} - -const timeOptions: Intl.DateTimeFormatOptions = { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', -}; - -const fullOptions: Intl.DateTimeFormatOptions = { - dateStyle: 'full', - timeStyle: 'long', -}; - type Anchor = { id: string; top: number }; /** @@ -97,6 +69,7 @@ function EventRow({ isExpanded: boolean; onToggle: () => void; }) { + const { formatDate, formatTime } = useDateTime(); const profile = event.actor === null ? null : UserController.show(event.actor).url; const isProfile = @@ -107,16 +80,17 @@ function EventRow({

  1. (null); const anchor = useRef(null); diff --git a/resources/js/components/operations/health-failed-jobs.tsx b/resources/js/components/operations/health-failed-jobs.tsx index 57a0195f9..643aff17b 100644 --- a/resources/js/components/operations/health-failed-jobs.tsx +++ b/resources/js/components/operations/health-failed-jobs.tsx @@ -9,11 +9,12 @@ import FailedJobsController from '@/actions/App/Http/Controllers/FailedJobsContr import { EmptyFeed } from '@/components/commit-feed'; import { Panel } from '@/components/operations/section'; import { Button } from '@/components/ui/button'; +import { useDateTime } from '@/hooks/use-date-time'; import { useListSlide } from '@/hooks/use-list-slide'; import { useNow } from '@/hooks/use-now'; import { PAGE_PROPS } from '@/layouts/operations/layout'; import { formatCount } from '@/lib/numbers'; -import { cn, formatFullDateTime, formatRelativeTime } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import type { FailedJob, FailedJobs } from '@/types'; const LEAVE_DELAY_MS = 700; @@ -29,6 +30,7 @@ function FailedJobRow({ failedJob: FailedJob; hasReplacement: boolean; }) { + const { formatFullDateTime, formatRelativeTime } = useDateTime(); const now = useNow(); const [state, setState] = useState< 'idle' | 'confirming' | 'retrying' | 'forgetting' diff --git a/resources/js/components/operations/health-failures.tsx b/resources/js/components/operations/health-failures.tsx index 7d7bf2892..97a09b4d1 100644 --- a/resources/js/components/operations/health-failures.tsx +++ b/resources/js/components/operations/health-failures.tsx @@ -2,13 +2,9 @@ import { ColumnChart } from '@/components/operations/charts'; import { classBasename } from '@/components/operations/health-failed-jobs'; import { Delta } from '@/components/operations/overview-delta'; import { Panel } from '@/components/operations/section'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; import { formatCount } from '@/lib/numbers'; -import { - formatFullDateTime, - formatRelativeTime, - readerClock, -} from '@/lib/utils'; import type { FailureTrend } from '@/types'; const SERIES = [ @@ -19,15 +15,6 @@ const SERIES = [ }, ]; -function formatHour(at: string): string { - return new Date(at).toLocaleTimeString('en', { - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - timeZone: readerClock().timeZone, - }); -} - /** * How many jobs failed in the last day against the day before, hour by hour, and which jobs failed most. */ @@ -38,6 +25,8 @@ export function FailuresPanel({ failureTrend: FailureTrend; className?: string; }) { + const { formatTime, formatFullDateTime, formatRelativeTime } = + useDateTime(); const now = useNow(); const top = Math.max(0, ...failureTrend.by_job.map((job) => job.count)); @@ -63,7 +52,7 @@ export function FailuresPanel({ series={SERIES} height={140} label="Failed jobs per hour, the last 24 hours" - formatBucket={formatHour} + formatBucket={formatTime} unit="jobs" /> diff --git a/resources/js/components/operations/overview-events.tsx b/resources/js/components/operations/overview-events.tsx index 39b853a2a..a5e7aba19 100644 --- a/resources/js/components/operations/overview-events.tsx +++ b/resources/js/components/operations/overview-events.tsx @@ -3,9 +3,10 @@ import { Circle, CircleAlert, CircleX, Info } from 'lucide-react'; import OperationsEventController from '@/actions/App/Http/Controllers/OperationsEventController'; import { EmptyFeed } from '@/components/commit-feed'; import { Panel } from '@/components/operations/section'; +import { useDateTime } from '@/hooks/use-date-time'; import { useListSlide } from '@/hooks/use-list-slide'; import { useNow } from '@/hooks/use-now'; -import { cn, formatFullDateTime, formatRelativeTime } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import type { OperationsEvent, OperationsEventLevel } from '@/types'; const LEVELS: Record< @@ -19,6 +20,7 @@ const LEVELS: Record< }; function EventRow({ event, now }: { event: OperationsEvent; now: Date }) { + const { formatFullDateTime, formatRelativeTime } = useDateTime(); const level = LEVELS[event.level]; const meta = [ event.source, diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index e59fe21eb..0674a18da 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -7,13 +7,9 @@ import { CommitPoll } from '@/components/commit-poll'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { LinkPreview } from '@/components/link-preview'; import { UserAvatar } from '@/components/user-avatar'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; -import { - cn, - formatFullDateTime, - formatRelativeTime, - isDeleted, -} from '@/lib/utils'; +import { cn, isDeleted } from '@/lib/utils'; import type { DeletedCommit, QuotedCommit as QuotedCommitData } from '@/types'; export function QuotedCommit({ @@ -25,6 +21,7 @@ export function QuotedCommit({ isPreview?: boolean; label?: string; }) { + const { formatFullDateTime, formatRelativeTime } = useDateTime(); const now = useNow(); if (commit === null || isDeleted(commit)) { return ( diff --git a/resources/js/components/schedule-picker.tsx b/resources/js/components/schedule-picker.tsx index 06bdb6001..cc36b0b4e 100644 --- a/resources/js/components/schedule-picker.tsx +++ b/resources/js/components/schedule-picker.tsx @@ -14,8 +14,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; -import { cn, dateLocale } from '@/lib/utils'; +import { cn } from '@/lib/utils'; export const maxScheduleDays = 30; @@ -62,6 +63,7 @@ export function SchedulePicker({ describedBy?: string; }) { const [isOpen, setIsOpen] = useState(false); + const dateTime = useDateTime(); const now = useNow(); const selected = value === '' ? undefined : new Date(value); const lastDay = new Date( @@ -70,7 +72,6 @@ export function SchedulePicker({ now.getDate() + maxScheduleDays, ); const isToday = selected !== undefined && isSameDay(selected, now); - const hourFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }); const change = (date: Date, hour: number, minute: number) => onChange( @@ -103,13 +104,7 @@ export function SchedulePicker({