From 67461bcbcd1bdcd6cd0ad48511089031021fd939 Mon Sep 17 00:00:00 2001 From: Robert Holak <423088+rholak@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:26 -0500 Subject: [PATCH 01/11] feat: add private date and time display preferences Keep date order and clock format in Appearance while preserving existing timezone, scheduling, and relative-day behavior. --- README.md | 6 + app/Actions/CreateExport.php | 2 + app/Actions/UpdateUserDateTime.php | 20 +++ app/Enums/DateFormat.php | 11 ++ app/Enums/TimeFormat.php | 11 ++ .../Controllers/UserDateTimeController.php | 33 ++++ app/Http/Middleware/HandleInertiaRequests.php | 4 + .../Requests/UpdateUserDateTimeRequest.php | 29 ++++ app/Models/User.php | 10 ++ ...d_date_and_time_formats_to_users_table.php | 18 ++ resources/js/components/activity-graph.tsx | 38 ++--- resources/js/components/changelog-dialog.tsx | 10 +- resources/js/components/changelog-lane.tsx | 9 +- resources/js/components/changelog-popover.tsx | 5 +- .../js/components/chat/conversation-list.tsx | 4 +- .../components/chat/conversation-thread.tsx | 25 +-- resources/js/components/commit-card.tsx | 4 +- resources/js/components/commit-feed.tsx | 4 +- resources/js/components/commit-thread.tsx | 3 +- .../js/components/date-time-settings.tsx | 157 ++++++++++++++++++ resources/js/components/quoted-commit.tsx | 9 +- resources/js/components/schedule-picker.tsx | 29 ++-- resources/js/components/search-dialog.tsx | 4 +- resources/js/components/timestamp-tooltip.tsx | 10 +- resources/js/components/timezone-picker.tsx | 9 +- resources/js/hooks/use-date-time.ts | 13 ++ resources/js/lib/changelog.ts | 13 +- resources/js/lib/date-time.ts | 127 ++++++++++++++ resources/js/lib/utils.ts | 114 ++----------- resources/js/pages/appearance/update.tsx | 3 + resources/js/pages/device/index.tsx | 6 +- resources/js/pages/moderation/reports.tsx | 3 +- resources/js/pages/notification/index.tsx | 13 +- resources/js/pages/operations/show.tsx | 4 +- .../js/pages/scheduled-commits/index.tsx | 12 +- resources/js/pages/user-profile/edit.tsx | 4 +- resources/js/pages/user/show.tsx | 9 +- resources/js/types/auth.ts | 4 + routes/web.php | 2 + .../Controllers/ExportControllerTest.php | 4 + .../UserDateTimeControllerTest.php | 73 ++++++++ .../UserTimezoneControllerTest.php | 4 +- tests/Unit/ActivityCells.test.tsx | 33 +++- tests/Unit/DateTime.test.ts | 145 ++++++++++++++++ tests/Unit/ReportResolution.test.tsx | 4 + tsconfig.json | 1 + 46 files changed, 839 insertions(+), 216 deletions(-) create mode 100644 app/Actions/UpdateUserDateTime.php create mode 100644 app/Enums/DateFormat.php create mode 100644 app/Enums/TimeFormat.php create mode 100644 app/Http/Controllers/UserDateTimeController.php create mode 100644 app/Http/Requests/UpdateUserDateTimeRequest.php create mode 100644 database/migrations/2026_10_19_170000_add_date_and_time_formats_to_users_table.php create mode 100644 resources/js/components/date-time-settings.tsx create mode 100644 resources/js/hooks/use-date-time.ts create mode 100644 resources/js/lib/date-time.ts create mode 100644 tests/Feature/Controllers/UserDateTimeControllerTest.php create mode 100644 tests/Unit/DateTime.test.ts diff --git a/README.md b/README.md index 94e195109..6019f6154 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ The setup script installs dependencies, creates `.env`, generates an app key, ru With `APP_ENV=local`, run `composer dev`, visit `http://localhost:8000` (or your configured `APP_URL`), and sign in: a modal asks for a username and signs you in as that user, creating it if it doesn't exist yet. GitHub isn't involved at all. The routes are only registered in the local environment, see `routes/web.php`. +### Date & time preferences + +Under Settings → Appearance → Date & time, users can choose month-first (`Oct 19`) or day-first (`19 Oct`) dates and a 12- or 24-hour clock independently. Both default to the browser's settings; month and weekday names stay in English. Overrides are saved on the account and are private. Timezone remains under Settings → Profile and is shared in timestamp tooltips. Each section saves independently. + +These preferences change presentation only. Existing timezones, Today/Yesterday labels, day grouping, scheduling, relative thresholds, countdowns, machine-readable timestamps, and UTC activity buckets keep their existing behavior. + ### GitHub login Outside the local environment, users sign in with GitHub. Create a [GitHub OAuth app](https://github.com/settings/developers) with the callback URL `{APP_URL}/auth/github/callback`, then set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in `.env`. diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 1a44e8285..31498a19b 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -51,6 +51,8 @@ public function handle(User $user): array 'preferred_lane' => $user->preferred_lane, 'timezone' => $user->timezone, 'translation_language' => $user->translation_language, + '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/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 cff4370a0..db730ea90 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -74,6 +74,10 @@ public function share(Request $request): array 'user' => fn (): ?User => $request->user()?->loadCounts(), 'isAdmin' => $isAdmin, '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 27da41b26..5651bc1a9 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\Database\Eloquent\Attributes\Hidden; @@ -47,6 +49,8 @@ * @property-read string|null $pinned_commit_id * @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 LivePlatform|null $live_platform * @property-read string|null $live_url * @property-read CarbonInterface|null $live_ends_at @@ -80,6 +84,8 @@ * @property-read int $followers_count */ #[Hidden([ + 'date_format', + 'time_format', 'github_id', 'email', 'remember_token', @@ -128,6 +134,8 @@ final class User extends Authenticatable * @var array */ protected $attributes = [ + 'date_format' => null, + 'time_format' => null, 'log' => Log::Following->value, 'translation_language' => null, 'pasteis_mode' => false, @@ -182,6 +190,8 @@ public function casts(): array 'pinned_commit_id' => 'string', 'timezone' => 'string', 'translation_language' => 'encrypted', + 'date_format' => DateFormat::class, + 'time_format' => TimeFormat::class, 'log' => Log::class, 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', 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/components/activity-graph.tsx b/resources/js/components/activity-graph.tsx index bf68cfe26..0a70e2750 100644 --- a/resources/js/components/activity-graph.tsx +++ b/resources/js/components/activity-graph.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, MouseEvent } from 'react'; import { useRef, useState } from 'react'; +import { useDateTime } from '@/hooks/use-date-time'; import { formatCount } from '@/lib/numbers'; import { cn } from '@/lib/utils'; import type { Activity, ActivityBucket, ActivityScale } from '@/types'; @@ -54,21 +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 { - return [time.getHours(), time.getMinutes()] - .map((part) => String(part).padStart(2, '0')) - .join(':'); -} - function count(total: number, [singular, plural]: ActivityNoun): string { return `${formatCount(total)} ${total === 1 ? singular : plural}`; } @@ -92,19 +78,20 @@ 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); const end = new Date(start.getTime() + 3_600_000); const isToday = start.toDateString() === new Date().toDateString(); - 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 { @@ -116,6 +103,7 @@ function stripLabel( scale: ActivityScale, index: number, length: number, + dateTime: ReturnType, ): string | null { if (scale === 'week') { const day = utcDay(bucket.date); @@ -127,14 +115,14 @@ function stripLabel( const start = new Date(bucket.date); return start.getHours() % 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; } @@ -219,6 +207,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; @@ -297,7 +286,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)} )} @@ -327,6 +316,7 @@ function Strip({ onEnter, onLeave, }: CellsProps & { scale: ActivityScale; summary: string }) { + const dateTime = useDateTime(); const columns = { gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))`, }; @@ -363,7 +353,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 767bb82f9..46392690a 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'; @@ -14,6 +15,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; +import { useNow } from '@/hooks/use-now'; import type { Changelog, ChangelogDay, @@ -34,8 +36,14 @@ function DaySection({ isRevealed: boolean; }) { 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 c6e8ffb1d..90054385f 100644 --- a/resources/js/components/changelog-lane.tsx +++ b/resources/js/components/changelog-lane.tsx @@ -1,13 +1,9 @@ import { ArrowUpRight } from 'lucide-react'; import type { Ref } from 'react'; +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'; export function changelogLaneColor(author: ChangelogAuthor): string { return laneColor(authorLane(author)); @@ -93,6 +89,7 @@ export function ChangelogLane({ firstLinkRef?: Ref; timeZone?: string; }) { + 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 ce27d341d..e2cc9442d 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 15538026a..44703043d 100644 --- a/resources/js/components/chat/conversation-list.tsx +++ b/resources/js/components/chat/conversation-list.tsx @@ -8,10 +8,11 @@ import { MessageStatusGlyph } from '@/components/chat/message-status'; import { eventText, MemberStack, orgTitle } from '@/components/chat/org'; import { Skeleton } from '@/components/ui/skeleton'; import { UserAvatar } from '@/components/user-avatar'; +import { useDateTime } from '@/hooks/use-date-time'; import { useNow } from '@/hooks/use-now'; import { 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({ @@ -79,6 +80,7 @@ function ConversationRow({ onBlocked?: () => void; isCompact: boolean; }) { + const { formatRelativeTime } = useDateTime(); const now = useNow(); const [isMenuOpen, setIsMenuOpen] = useState(false); const { partner, last_message: lastMessage, ahead } = conversation; diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 892f85ecb..96edb074d 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -46,6 +46,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { UserAvatar } from '@/components/user-avatar'; 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 { useSecretWarning } from '@/hooks/use-secret-warning'; @@ -67,13 +68,7 @@ import { doubleTapReaction } from '@/lib/reactions'; import type { ReactionUsage } from '@/lib/reactions'; import { restoreScrollAnchor, scrollAnchorOf } from '@/lib/scroll-anchor'; import type { ScrollAnchor } from '@/lib/scroll-anchor'; -import { - characterCount, - cn, - formatDayLabel, - laneColor, - localDayKey, -} from '@/lib/utils'; +import { characterCount, cn, laneColor, localDayKey } from '@/lib/utils'; import type { Message, MessageReply, @@ -146,6 +141,7 @@ export function ConversationThread({ onDropped?: () => void; }) { const thread = useThread(target); + const dateTime = useDateTime(); const usage = useChat().reactionUsage ?? noUsage; const isOrg = target.kind === 'org'; // Renaming an org updates the thread's target, not the one this was opened with. @@ -165,8 +161,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), @@ -487,7 +484,9 @@ function itemsOf( headUntil: number, user: User, isOrg: boolean, + dateTime: ReturnType, ): Item[] { + const { formatDayLabel } = dateTime; const bubbles: { bubble: Bubble; at: string; @@ -726,6 +725,7 @@ function BubbleWithMeta({ onReact: ((messageId: number, emoji: string) => void) | null; }) { const [isReacting, setIsReacting] = useState(false); + const { formatTime: timeOf } = useDateTime(); const [isTrayOpen, setIsTrayOpen] = useState(false); const trayId = useId(); const reactButton = useRef(null); @@ -1048,13 +1048,6 @@ function MetaLine({ ); } -function timeOf(date: string): string { - return new Date(date).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); -} - /** * The draft being written to a conversation: its body and images, kept per conversation across pages and the dock, * the link chosen for its card, and any secrets found in it. diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 7005edc83..7279cfc3c 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -40,6 +40,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { UserPreview } from '@/components/user-preview'; +import { useDateTime } from '@/hooks/use-date-time'; import { useCommitNotFound, useIsCommitDeleted, @@ -50,8 +51,6 @@ import { commitMutationOptions } from '@/lib/commit-mutation-options'; import { measureCommitSize } from '@/lib/commit-size'; import { cn, - formatFullDateTime, - formatRelativeTime, isDeleted, isWithinSeconds, laneColor, @@ -109,6 +108,7 @@ export function CommitCard({ props: { auth }, component, } = usePage(); + const { formatFullDateTime, formatRelativeTime } = useDateTime(); const now = useNow(); const { bodyRef, ...translation } = useCommitTranslation(commit); const nodeRef = useRef(null); diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index f6a87261a..7441e1bc1 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -4,13 +4,14 @@ import type { PointerEvent, ReactNode } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react'; import { CommitCard } from '@/components/commit-card'; 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; @@ -42,6 +43,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 4f022ae23..eb8cdc3a1 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -50,6 +50,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, @@ -74,7 +75,6 @@ import { hasModKey } from '@/lib/keyboard'; import { characterCount, cn, - formatRelativeTime, isDeleted, laneOf, laneStrokeStyle, @@ -967,6 +967,7 @@ function CommitReply({ onDeleted: () => void; }) { const { auth } = usePage().props; + const { formatRelativeTime } = useDateTime(); const now = useNow(); const { bodyRef, ...translation } = useCommitTranslation(reply); const wasDeleted = useIsCommitDeleted(reply.id); diff --git a/resources/js/components/date-time-settings.tsx b/resources/js/components/date-time-settings.tsx new file mode 100644 index 000000000..73f903bf1 --- /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 }) => ( + <> +
    +
    + + + +
    +
    + + + +
    +
    +

    + Browser default uses your browser's date order and + clock format. Month names stay in English. +

    +

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

    + + + )} +
    +
    + ); +} diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index 8c7e090da..3d94afe7a 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({ @@ -23,6 +19,7 @@ export function QuotedCommit({ commit: QuotedCommitData | DeletedCommit | null; isPreview?: boolean; }) { + 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..92ffd0b9f 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({