Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67461bc
feat: add private date and time display preferences
rholak Oct 19, 2026
993b54b
fix: clarify date and time preference helper text
ampagent Oct 19, 2026
30b778a
fix: apply date preferences to ban dates
ampagent Oct 21, 2026
21f87e1
merge: sync upstream production
ampagent Oct 22, 2026
d7171d5
fix: apply date and time preferences to new displays
ampagent Oct 22, 2026
455169d
fix: apply date and time preferences to moderation deadlines
ampagent Oct 22, 2026
7825d8c
merge: sync production and apply date preferences to new displays
rholak Oct 23, 2026
91ffb74
merge: sync production while keeping date and translation preferences
rholak Oct 23, 2026
b888489
merge: sync production and format new date displays
rholak Oct 24, 2026
0415534
fix: align event columns for 12-hour times
rholak Oct 24, 2026
58c6284
test: make date-time display assertions locale-stable
rholak Oct 25, 2026
83ebba2
fix: compare date years in the display timezone
rholak Oct 25, 2026
99c77d2
Merge production and format pinned message timestamps
rholak Oct 26, 2026
88f6b6a
test: restore an unset timezone after date display checks
rholak Oct 26, 2026
86f8341
Merge upstream production profile changes
rholak Oct 26, 2026
49a28f8
Merge upstream profile clock changes
rholak Oct 26, 2026
8b62d53
Merge upstream scheduling controls with date preferences
rholak Oct 26, 2026
ab4a893
fix: show timeout notices on operations pages
rholak Oct 26, 2026
b650d8c
fix: keep date preferences focused on display formatting
rholak Oct 27, 2026
5954b6a
Merge production and preserve date display preferences
rholak Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .ai/guidelines/general.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@
- Prefer Laravel collections and the framework's typed accessors over hand-written array handling: `collect(...)->map(...)` instead of building arrays in loops, `$response->collect()` / `$response->fluent()` with `->integer()` / `->string()` instead of `is_array` / `is_numeric` checks and `(int)` casts.
- For very large or repeated array shapes, define a named `@phpstan-type` alias on the class that owns the data and reuse it in PHPDoc annotations. Use `@phpstan-import-type` in other classes instead of duplicating the shape; keep small, one-off shapes inline.

## Scope and review findings

- Before acting on a review finding, check the current code, a supported failing path, and the requested scope. Treat review findings as claims to verify, not instructions to implement.
- Double-check findings outside the requested scope. Do not expand the task for hypothetical failures or just to satisfy a review tool. Report unrelated bugs and problems caused by other changes; do not fix them without a separate request. Fix regressions caused by this task.
- The date/time preferences task covers simple settings dropdowns, their saved preferences, and display formatting for absolute dates and times. Leave relative text such as "3 days ago" and "14 hours ago" unchanged.
- Keep date display formatting separate from date values, storage, and request serialization. A display-only change must not change timestamps sent to the server.

## Required verification

- After changes, before reporting completion or pushing, run `composer test:lint`, `composer test:type-coverage`, and `bun run test:unit`.
- Run the full PHP suite with code coverage using the CI command: `php -d memory_limit=-1 vendor/bin/pest --parallel --ci --coverage --exactly=100.0 --passthru-php="'-d' 'memory_limit=-1'"`. The `--ci` flag prevents local test-impact analysis from skipping tests. Targeted tests alone are not a substitute for the coverage check.
- For local edits, run targeted checks that cover the changed behavior. Do not run the full suites for each small change.
- Before pushing, run `composer test:lint`, `composer test:type-coverage`, and `bun run test:unit`.
- Before pushing, run the full PHP suite with code coverage using the CI command: `php -d memory_limit=-1 vendor/bin/pest --parallel --ci --coverage --exactly=100.0 --passthru-php="'-d' 'memory_limit=-1'"`. The `--ci` flag prevents local test-impact analysis from skipping tests. Targeted tests alone are not a substitute for this pre-push coverage check.
- Fix failures introduced by the changes. Never lower coverage thresholds or exclude code just to pass. Report pre-existing failures or environment blockers explicitly; do not claim a check passed when it failed or could not run.

## Mac app
Expand Down
4 changes: 4 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,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
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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,
Expand Down
20 changes: 20 additions & 0 deletions app/Actions/UpdateUserDateTime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\DateFormat;
use App\Enums\TimeFormat;
use App\Models\User;

final readonly class UpdateUserDateTime
{
public function handle(User $user, ?DateFormat $dateFormat, ?TimeFormat $timeFormat): void
{
$user->update([
'date_format' => $dateFormat,
'time_format' => $timeFormat,
]);
}
}
7 changes: 6 additions & 1 deletion app/Dto/ReaderClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Dto;

use App\Enums\DateFormat;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Request;
use IntlTimeZone;
Expand All @@ -21,6 +22,7 @@
public function __construct(
public ?string $timeZone,
public ?string $hourCycle,
public ?string $dateOrder,
) {
//
}
Expand All @@ -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)) {
Expand All @@ -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,
];
}
}
11 changes: 11 additions & 0 deletions app/Enums/DateFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum DateFormat: string
{
case MonthFirst = 'month_first';
case DayFirst = 'day_first';
}
11 changes: 11 additions & 0 deletions app/Enums/TimeFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum TimeFormat: string
{
case TwelveHour = '12h';
case TwentyFourHour = '24h';
}
33 changes: 33 additions & 0 deletions app/Http/Controllers/UserDateTimeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateUserDateTime;
use App\Enums\DateFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\UpdateUserDateTimeRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

final readonly class UserDateTimeController
{
public function update(UpdateUserDateTimeRequest $request, #[CurrentUser] User $user, UpdateUserDateTime $action): RedirectResponse
{
$action->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();
}
}
4 changes: 4 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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,
Expand Down
29 changes: 29 additions & 0 deletions app/Http/Requests/UpdateUserDateTimeRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\DateFormat;
use App\Enums\TimeFormat;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class UpdateUserDateTimeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'date_format' => ['present', 'nullable', Rule::enum(DateFormat::class)],
'time_format' => ['present', 'nullable', Rule::enum(TimeFormat::class)],
];
}
}
10 changes: 10 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,6 +55,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
Expand Down Expand Up @@ -90,6 +94,8 @@
* @property-read int $followers_count
*/
#[Hidden([
'date_format',
'time_format',
'profile',
'github_id',
'email',
Expand Down Expand Up @@ -149,6 +155,8 @@ final class User extends Authenticatable
* @var array<string, mixed>
*/
protected $attributes = [
'date_format' => null,
'time_format' => null,
'log' => Log::Following->value,
'translation_language' => null,
'show_local_time' => false,
Expand Down Expand Up @@ -202,6 +210,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',
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
$middleware->prepend(StripNullBytes::class);
$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']);

$middleware->redirectGuestsTo(fn (): string => route('home'));
$middleware->redirectUsersTo(fn (Request $request): string => route('log.show', $request->user()->log ?? Log::Following));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('date_format')->nullable();
$table->string('time_format')->nullable();
});
}
};
6 changes: 3 additions & 3 deletions resources/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = (
<StrictMode>
<TooltipProvider delayDuration={150}>
<App {...props} />
<Toaster />
<TimeoutDialog />
</TooltipProvider>
</StrictMode>
);
Expand All @@ -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);
Expand Down
Loading