diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 5e735c48bc..59e43e4c36 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -510,8 +510,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 13b11294ce..3acc10e615 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -94,6 +94,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 0000000000..87181932f4 --- /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 477ef6790e..151052e419 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 0000000000..9a8871d9d4 --- /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 ffe4a8cc35..730b512df3 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -139,6 +139,10 @@ public function share(Request $request): array ? Inertia::live(fn (): bool => resolve(Features::class)->enabled(Feature::OrganizationCommits), 'features', new Channel('features')) : false, 'isAdmin' => $isAdmin, + 'dateTime' => fn (): array => [ + 'dateFormat' => $user?->date_format, + 'timeFormat' => $user?->time_format, + ], 'automaticTranslation' => $live(fn (): bool => $user->automatic_translation ?? true, UserUpdate::Account), // The language translations are read in, saved or from the browser, so a change saved elsewhere reaches // the translations already shown. diff --git a/app/Http/Requests/UpdateUserDateTimeRequest.php b/app/Http/Requests/UpdateUserDateTimeRequest.php new file mode 100644 index 0000000000..375969269b --- /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 825d8d31b2..5e0ca96caf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,9 +7,11 @@ 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\RemotesFrom; +use App\Enums\TimeFormat; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; @@ -58,6 +60,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 @@ -96,6 +100,8 @@ * @property-read int $followers_count */ #[Hidden([ + 'date_format', + 'time_format', 'profile', 'github_id', 'email', @@ -161,6 +167,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, @@ -224,6 +232,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 a795fad12b..91958cedb8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -47,7 +47,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', 'event_theme', 'replies_per_page', 'time_zone', 'hour_cycle']); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'event_theme', '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 0000000000..89b3abcb87 --- /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 bacb232291..cc6284ed06 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'; @@ -78,13 +77,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 = ( - ); @@ -95,7 +94,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 937f6792be..b6801d2aa1 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; @@ -312,7 +299,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)} )} @@ -342,6 +329,7 @@ function Strip({ onEnter, onLeave, }: CellsProps & { scale: ActivityScale; summary: string }) { + const dateTime = useDateTime(); const columns = { gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))`, }; @@ -378,7 +366,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 71eba7cd7b..8b50cd97d5 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, ChangelogCommit } from '@/lib/changelog'; import { changelogDayHeading, changelogDays } from '@/lib/changelog'; import { formatCount } from '@/lib/numbers'; @@ -34,8 +36,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.commits.slice(0, shownPerDay); const hidden = day.commits.slice(shownPerDay); return ( diff --git a/resources/js/components/changelog-lane.tsx b/resources/js/components/changelog-lane.tsx index 815540f561..ccd65e7b92 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, ChangelogCommit } 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 { @@ -152,6 +148,7 @@ export function ChangelogLane({ timeZone?: string; onNavigate?: () => void; }) { + const { formatFullDateTime, formatRelativeTime } = useDateTime(); return (
    {commits.map((commit, index) => { diff --git a/resources/js/components/changelog-popover.tsx b/resources/js/components/changelog-popover.tsx index 78bb48fbf2..d3a9cc8624 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.commits ?? [], 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.commits ?? []).slice(0, latestCount); return ( diff --git a/resources/js/components/chat/conversation-list.tsx b/resources/js/components/chat/conversation-list.tsx index 7e7fe4d5fc..02b961bf7f 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 ca50a64f20..d488a97681 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -62,6 +62,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'; @@ -90,14 +91,7 @@ import { isRemoteMuted, playPing } from '@/lib/remote-notifications'; import { restoreScrollAnchor, scrollAnchorOf } from '@/lib/scroll-anchor'; import type { ScrollAnchor } from '@/lib/scroll-anchor'; import { watchSeenMentions } from '@/lib/seen-mentions'; -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, @@ -171,6 +165,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'; @@ -204,8 +199,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), @@ -1015,7 +1011,9 @@ function itemsOf( headUntil: number, user: User, isOrg: boolean, + dateTime: ReturnType, ): Item[] { + const { formatDayLabel } = dateTime; const bubbles: { bubble: Bubble; at: string; @@ -1283,6 +1281,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); @@ -1566,7 +1565,7 @@ function BubbleWithMeta({ {status === 'pulled' && target.kind === 'direct' && pulls.readAt && - ` ${formatClockTime(pulls.readAt)}`} + ` ${timeOf(pulls.readAt)}`} {pulledBy.length > 0 && ' by'} {pulledBy.length > 0 && ( @@ -1578,7 +1577,7 @@ function BubbleWithMeta({ bubble.kind === 'message' && ( {showsStatus && pulledBy.length > 0 && ( <> diff --git a/resources/js/components/chat/mentions-bell.tsx b/resources/js/components/chat/mentions-bell.tsx index 4a8b44759c..c24b2f8600 100644 --- a/resources/js/components/chat/mentions-bell.tsx +++ b/resources/js/components/chat/mentions-bell.tsx @@ -14,9 +14,10 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { UserAvatar } from '@/components/user-avatar'; +import { useDateTime } from '@/hooks/use-date-time'; import { clearMentions } from '@/lib/chat'; import { formatExactCount } from '@/lib/numbers'; -import { cn, formatClockTime, formatDayLabel } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import type { Message, OrgRef } from '@/types'; /** @@ -36,6 +37,7 @@ export function MentionsBell({ isCompact: boolean; onJump: (messageId: number) => Promise; }) { + const { formatDayLabel, formatTime: formatClockTime } = useDateTime(); const [isOpen, setIsOpen] = useState(false); const headingId = useId(); const clear = (messageIds?: number[]) => diff --git a/resources/js/components/chat/pinned-messages.tsx b/resources/js/components/chat/pinned-messages.tsx index 26801fb32c..705ad86377 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'; @@ -554,6 +555,7 @@ function PinnedItem({ handedFocus: RefObject>; onGone: () => void; }) { + const { formatDayLabel, formatTime } = useDateTime(); const reactButton = useRef(null); const unpinButton = useRef(null); const trayId = useId(); @@ -671,7 +673,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 edd740d31f..38d8aacfbc 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -44,6 +44,7 @@ import { import { TimestampTooltip } from '@/components/timestamp-tooltip'; import { Button } from '@/components/ui/button'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { useCommitNotFound, useIsCommitDeleted, @@ -56,8 +57,6 @@ import { measureCommitSize } from '@/lib/commit-size'; import { authorLaneColor, cn, - formatFullDateTime, - formatRelativeTime, isDeleted, isWithinSeconds, shortHash, @@ -119,6 +118,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 32a573ab0a..83ecb10448 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 { authorLaneColor, cn, formatDayLabel, localDayKey } from '@/lib/utils'; +import { authorLaneColor, cn, localDayKey } from '@/lib/utils'; import type { Commit, CursorPaginated } from '@/types'; const interludeAfter = 5; @@ -46,6 +47,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 4c36788f03..29a2162ea6 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -70,6 +70,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, @@ -108,7 +109,6 @@ import { reconcileReplies } from '@/lib/thread-replies'; import { characterCount, cn, - formatRelativeTime, isDeleted, laneOf, laneStrokeStyle, @@ -1457,6 +1457,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 0000000000..9fda876929 --- /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/link-topic-commits.tsx b/resources/js/components/link-topic-commits.tsx index 7605078558..d05f95439c 100644 --- a/resources/js/components/link-topic-commits.tsx +++ b/resources/js/components/link-topic-commits.tsx @@ -29,16 +29,11 @@ import { RailDot, railDotRing } from '@/components/graph-rail'; import { QuotedCommit } from '@/components/quoted-commit'; import { TimestampTooltip } from '@/components/timestamp-tooltip'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { useIsCommitDeleted } from '@/hooks/use-deleted-resources'; import { useNow } from '@/hooks/use-now'; import { formatCount } from '@/lib/numbers'; -import { - cn, - formatRelativeTime, - isDeleted, - laneOf, - shortHash, -} from '@/lib/utils'; +import { cn, isDeleted, laneOf, shortHash } from '@/lib/utils'; import { show } from '@/routes/user'; import type { Commit } from '@/types'; @@ -94,6 +89,7 @@ function LinkTopicCommit({ commit: Commit; graphParent: string; }) { + const { formatRelativeTime } = useDateTime(); const now = useNow(); const thread = useThread(commit); const { bodyRef, ...translation } = useCommitTranslation(commit); diff --git a/resources/js/components/local-time.tsx b/resources/js/components/local-time.tsx index 16d9074425..b2b071f073 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 307ab511e5..a6129efa34 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, @@ -193,6 +184,7 @@ export function MultiLineChart({ label: string; unit: string; }) { + const { formatZonedDate } = useDateTime(); const [ref, width] = useWidth(); const [hovered, setHovered] = useState(null); const ticks = niceTicks( @@ -333,7 +325,7 @@ export function MultiLineChart({ textAnchor="middle" className="fill-muted-foreground font-mono text-[11px]" > - {shortDate(date)} + {formatZonedDate(date, 'UTC')} ))} {firstTracked > 0 && @@ -358,7 +350,8 @@ export function MultiLineChart({ className="fill-muted-foreground stroke-card font-mono text-[11px] [paint-order:stroke]" strokeWidth={3} > - tracked from {shortDate(markDate)} + tracked from{' '} + {formatZonedDate(markDate, 'UTC')} )} @@ -469,7 +462,7 @@ export function MultiLineChart({ {active && hovered !== null && (

    - {shortDate(active.date)} + {formatZonedDate(active.date, 'UTC')} {active.date === partialDate && ( {' '} @@ -512,7 +505,9 @@ export function MultiLineChart({ {days.map((day, index) => ( - {shortDate(day.date)} + + {formatZonedDate(day.date, 'UTC')} + {series.map((line, at) => ( {visible(at, index) @@ -544,7 +539,7 @@ export function LineChart({ height = 200, format = compactNumber, formatTick = compactNumber, - formatDate = shortDate, + formatDate: formatPointDate, label, unit, }: { @@ -557,6 +552,7 @@ export function LineChart({ label: string; unit: string; }) { + const { formatDate } = useDateTime(); const [ref, width] = useWidth(); const [hovered, setHovered] = useState(null); const days: (LinePoint & @@ -676,7 +672,11 @@ export function LineChart({ } className="fill-muted-foreground font-mono text-[11px]" > - {formatDate(day.date)} + {formatPointDate?.(day.date) ?? + formatDate(day.date, { + timeZone: 'UTC', + year: false, + })} ), )} @@ -751,7 +751,11 @@ export function LineChart({ {active && hovered !== null && (

    - {formatDate(active.date)} + {formatPointDate?.(active.date) ?? + formatDate(active.date, { + timeZone: 'UTC', + year: false, + })} {active.isForecast && ( {' '} @@ -785,7 +789,14 @@ 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 53a2f327d1..5c5ffdfb00 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 57a0195f97..643aff17b0 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 7d7bf28921..97a09b4d11 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 39b853a2ab..a5e7aba19d 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 c605e4caa5..d2951834f0 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -7,13 +7,9 @@ import { CommitImages } from '@/components/commit-images'; import { CommitPoll } from '@/components/commit-poll'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { LinkPreview } from '@/components/link-preview'; +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 06bdb6001d..cc36b0b4e9 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({