Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
deed444
Fix maintenance time rendering
NomadDigita Oct 28, 2026
f78aae1
Serialize local maintenance times with browser timezone
NomadDigita Oct 28, 2026
c20da5d
Fix generated slugs and paginate operator content
NomadDigita Oct 28, 2026
99fd9f6
Allow operators to browse older notices and articles
NomadDigita Oct 28, 2026
f1d7ccf
Use a stable order for communications pagination
NomadDigita Oct 28, 2026
4af6962
Add stable ordering to editorial content queries
NomadDigita Oct 28, 2026
33b7e46
Track editor pagination independently from list mutations
NomadDigita Oct 28, 2026
1059572
Refresh paginated lists after editing records
NomadDigita Oct 28, 2026
b52e9a5
Add automatic, device, and icon theme controls
NomadDigita Oct 28, 2026
c380ffa
Resolve the theme from the saved time zone on the server
NomadDigita Oct 28, 2026
7f69dcc
Explain automatic, device, and manual theme preferences
NomadDigita Oct 28, 2026
c9d8939
Test timezone-based and device theme defaults
NomadDigita Oct 28, 2026
24cd1ba
Update theme preference contract checks
NomadDigita Oct 28, 2026
2d89e25
Style recognizable theme control icons
NomadDigita Oct 28, 2026
bccfb0e
Use the browser's current time zone for automatic themes
NomadDigita Oct 28, 2026
21bb910
Check current timezone-based theme behavior
NomadDigita Oct 28, 2026
95f3bbb
Align theme tests with persisted appearance modes
NomadDigita Oct 28, 2026
1a7784c
Test automatic and device theme selection
NomadDigita Oct 28, 2026
154d2a8
Update palette preference assertions
NomadDigita Oct 28, 2026
6f29768
Align palette test with saved theme modes
NomadDigita Oct 28, 2026
a90157e
Correct automatic theme behavior test assertions
NomadDigita Oct 28, 2026
24d95d0
Correct theme control tests
NomadDigita Oct 28, 2026
3f9fadb
Verify automatic and explicit appearance preferences
NomadDigita Oct 28, 2026
cdc61d4
Translate automatic theme preference copy
NomadDigita Oct 28, 2026
df98c59
Handle communications refresh errors after save
NomadDigita Oct 28, 2026
9eb13e5
Invalidate stale pagination after refresh
NomadDigita Oct 28, 2026
d8df8d7
Improve selected light theme icon contrast
NomadDigita Oct 28, 2026
52e33e3
Render saved theme mode on server
NomadDigita Oct 28, 2026
a2d73ee
Normalize and pass initial theme mode
NomadDigita Oct 28, 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
24 changes: 23 additions & 1 deletion app/api/operator/communications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,29 @@ function validDate(value: unknown, optional = false): string | null | undefined
}

function slugify(value: string) {
return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100);
return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100).replace(/-+$/g, "");
}

export async function GET(request: Request) {
const user = await getServerUser();
if (!user) return json({ error: "Sign in to continue." }, 401);
if (!isTidelightOperator(user)) return json({ error: "Operator access is required." }, 403);
const params = new URL(request.url).searchParams;
const kind = params.get("kind");
const offset = Number(params.get("offset") ?? "0");
if ((kind !== "maintenance" && kind !== "blog") || !Number.isInteger(offset) || offset < 0 || offset > 1000000) {
return json({ error: "Choose a valid content list and page offset." }, 400);
}
const client = createAdminClient();
const table = kind === "maintenance" ? "tic_status_maintenance" : "tic_blog_posts";
const columns = kind === "maintenance"
? "id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at"
: "id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at";
const orderColumn = kind === "maintenance" ? "starts_at" : "updated_at";
const { data, count, error } = await client.from(table).select(columns, { count: "exact" })
.order(orderColumn, { ascending: false }).order("id", { ascending: true }).range(offset, offset + 49);
if (error) return json({ error: "The content list could not be loaded." }, 500);
return json({ items: data ?? [], hasMore: offset + (data?.length ?? 0) < (count ?? 0) });
}

export async function POST(request: Request) {
Expand Down
2 changes: 1 addition & 1 deletion app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -6184,5 +6184,5 @@
"SOURCE-LINKED": "来源可追溯",
"PAPER-FIRST CONTROLS": "模拟交易优先控制",
"Illustrative workflow · no live activity shown": "示意工作流 · 未显示实时活动",
"Choose Dark or White. Dark is the default theme.": "请选择深色或白色主题。深色为默认主题。"
"Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.": "选择浅色或深色主题、跟随设备设置,或让 Tidelight 根据您所在时区的本地时间自动切换。"
}
20 changes: 17 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ import "./account-center.css";
import "./notifications/notifications.css";
import { getServerUser } from "@/lib/supabase/auth-user";

const validTheme = (value: string | undefined): value is "dark" | "white" => value === "dark" || value === "white";
function automaticTheme(timeZone: string) {
try {
const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone }).format(new Date()));
return hour >= 7 && hour < 19 ? "white" : "dark";
} catch {
return "dark";
}
}

const baseMetadata: Metadata = {
metadataBase: new URL("https://tidelight.app"),
alternates: { canonical: "https://tidelight.app" },
Expand Down Expand Up @@ -66,8 +76,12 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const cookieStore = await cookies();
const savedTheme = cookieStore.get("tidelight-theme")?.value;
const themeMode = cookieStore.get("tidelight-theme-mode")?.value;
const theme = themeMode === "manual" && (savedTheme === "white" || savedTheme === "dark") ? savedTheme : "dark";
const savedThemeMode = cookieStore.get("tidelight-theme-mode")?.value;
const themeMode = savedThemeMode === "manual" || savedThemeMode === "device" ? savedThemeMode : "auto";
const timeZone = cookieStore.get("tidelight-timezone")?.value ?? "UTC";
const theme = themeMode === "manual" && validTheme(savedTheme)
? savedTheme
: themeMode === "auto" ? automaticTheme(timeZone) : validTheme(savedTheme) ? savedTheme : automaticTheme(timeZone);
const requestHeaders = await headers();
const requestHost = (requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "").split(",")[0].trim().toLowerCase().replace(/:\d+$/, "");
const isPublicPackageHost = requestHost === "package.tidelight.app";
Expand All @@ -90,6 +104,6 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|| user?.email?.split("@")[0]
|| null;

return <html lang={language} data-language={language} data-theme={theme} suppressHydrationWarning><body><ThemeProvider initialTheme={theme}><LanguageProvider initialLanguage={language}><IntelligenceProvider>{isStandalonePublicHost ? children : <WorkspaceShell email={user?.email ?? null} avatarUrl={profile?.avatar_url ?? metadataAvatar} displayName={displayName} isOperator={isTidelightOperator(user)}>{children}</WorkspaceShell>}{isStandalonePublicHost ? null : <><SupportCompanion /><ProductQuickActions /></>}</IntelligenceProvider></LanguageProvider></ThemeProvider></body></html>;
return <html lang={language} data-language={language} data-theme={theme} data-theme-mode={themeMode} suppressHydrationWarning><body><ThemeProvider initialTheme={theme} initialMode={themeMode}><LanguageProvider initialLanguage={language}><IntelligenceProvider>{isStandalonePublicHost ? children : <WorkspaceShell email={user?.email ?? null} avatarUrl={profile?.avatar_url ?? metadataAvatar} displayName={displayName} isOperator={isTidelightOperator(user)}>{children}</WorkspaceShell>}{isStandalonePublicHost ? null : <><SupportCompanion /><ProductQuickActions /></>}</IntelligenceProvider></LanguageProvider></ThemeProvider></body></html>;
}

105 changes: 96 additions & 9 deletions app/operator/communications/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, type FormEvent, type ReactNode } from "react";
import { useRef, useState, type FormEvent, type ReactNode } from "react";
import type { EditorialPost, MaintenanceNotice } from "@/lib/tic/editorial-content";

function localDateTime(value: string | null | undefined) {
Expand All @@ -9,49 +9,136 @@
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}

function toIsoTimestamp(value: FormDataEntryValue | undefined) {
if (typeof value !== "string" || !value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

function FormField({ label, children }: { label: string; children: ReactNode }) {
return <label style={{ display: "grid", gap: 7, margin: "12px 0", fontWeight: 600 }}>{label}{children}</label>;
}

export function CommunicationsEditor({ initialMaintenance, initialPosts }: { initialMaintenance: MaintenanceNotice[]; initialPosts: EditorialPost[] }) {
const [maintenance, setMaintenance] = useState(initialMaintenance);
const [maintenanceOffset, setMaintenanceOffset] = useState(initialMaintenance.length);
const [posts, setPosts] = useState(initialPosts);
const [postsOffset, setPostsOffset] = useState(initialPosts.length);
const [maintenanceHasMore, setMaintenanceHasMore] = useState(initialMaintenance.length === 50);
const [postsHaveMore, setPostsHaveMore] = useState(initialPosts.length === 50);
const [maintenanceVisible, setMaintenanceVisible] = useState(12);
const [postsVisible, setPostsVisible] = useState(12);
const [loadingMore, setLoadingMore] = useState<"maintenance" | "blog" | null>(null);
const paginationGeneration = useRef({ maintenance: 0, blog: 0 });
const [editingMaintenance, setEditingMaintenance] = useState<MaintenanceNotice | null>(null);
const [editingPost, setEditingPost] = useState<EditorialPost | null>(null);
const [maintenanceMessage, setMaintenanceMessage] = useState("");
const [postMessage, setPostMessage] = useState("");
const [busy, setBusy] = useState(false);

async function showMore(kind: "maintenance" | "blog") {
const items = kind === "maintenance" ? maintenance : posts;
const offset = kind === "maintenance" ? maintenanceOffset : postsOffset;
const visible = kind === "maintenance" ? maintenanceVisible : postsVisible;
if (visible < items.length) {
kind === "maintenance" ? setMaintenanceVisible(visible + 12) : setPostsVisible(visible + 12);

Check warning on line 44 in app/operator/communications/editor.tsx

View workflow job for this annotation

GitHub Actions / validate

Expected an assignment or function call and instead saw an expression
return;
}
const hasMore = kind === "maintenance" ? maintenanceHasMore : postsHaveMore;
if (!hasMore) return;
const generation = paginationGeneration.current[kind];
setLoadingMore(kind);
try {
const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${offset}`);
const result = await response.json() as { items?: Array<MaintenanceNotice | EditorialPost>; hasMore?: boolean; error?: string };
if (!response.ok || !result.items) throw new Error(result.error ?? "Older entries could not be loaded.");
if (generation !== paginationGeneration.current[kind]) return;
if (kind === "maintenance") {
setMaintenance(current => [...current, ...(result.items as MaintenanceNotice[])]);
setMaintenanceOffset(offset + result.items.length);
setMaintenanceHasMore(Boolean(result.hasMore));
setMaintenanceVisible(visible + result.items.length);
} else {
setPosts(current => [...current, ...(result.items as EditorialPost[])]);
setPostsOffset(offset + result.items.length);
setPostsHaveMore(Boolean(result.hasMore));
setPostsVisible(visible + result.items.length);
}
} catch (error) {
if (generation === paginationGeneration.current[kind]) {
const message = error instanceof Error ? error.message : "Older entries could not be loaded.";
kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message);

Check warning on line 70 in app/operator/communications/editor.tsx

View workflow job for this annotation

GitHub Actions / validate

Expected an assignment or function call and instead saw an expression
}
} finally {
if (generation === paginationGeneration.current[kind]) setLoadingMore(null);
}
}

async function refreshList(kind: "maintenance" | "blog") {
paginationGeneration.current[kind] += 1;
setLoadingMore(current => current === kind ? null : current);
try {
const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`);
Comment thread
NomadDigita marked this conversation as resolved.
Resolved
const result = await response.json() as { items?: Array<MaintenanceNotice | EditorialPost>; hasMore?: boolean };
if (!response.ok || !result.items) return false;
if (kind === "maintenance") {
setMaintenance(result.items as MaintenanceNotice[]);
setMaintenanceOffset(result.items.length);
setMaintenanceHasMore(Boolean(result.hasMore));
setMaintenanceVisible(12);
} else {
setPosts(result.items as EditorialPost[]);
setPostsOffset(result.items.length);
setPostsHaveMore(Boolean(result.hasMore));
setPostsVisible(12);
}
return true;
} catch {
return false;
}
}

async function submit(event: FormEvent<HTMLFormElement>, kind: "maintenance" | "blog") {
event.preventDefault();
const form = event.currentTarget;
const raw = Object.fromEntries(new FormData(form).entries());
const editing = kind === "maintenance" ? editingMaintenance : editingPost;
const publishedAt = editingPost?.published_at;
const payload = kind === "maintenance"
? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: raw.startsAt, endsAt: raw.endsAt || null, status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) }
? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: toIsoTimestamp(raw.startsAt), endsAt: toIsoTimestamp(raw.endsAt), status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) }
: { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, category: raw.category, slug: raw.slug, body: raw.body, status: raw.status, publishedAt };
setBusy(true);
kind === "maintenance" ? setMaintenanceMessage("") : setPostMessage("");

Check warning on line 111 in app/operator/communications/editor.tsx

View workflow job for this annotation

GitHub Actions / validate

Expected an assignment or function call and instead saw an expression
try {
const response = await fetch("/api/operator/communications", { method: editing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
const result = await response.json() as { item?: MaintenanceNotice | EditorialPost; error?: string };
if (!response.ok || !result.item) throw new Error(result.error ?? "The update could not be saved.");
const refreshed = await refreshList(kind);
if (kind === "maintenance") {
const item = result.item as MaintenanceNotice;
setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at)));
if (!refreshed) {
const item = result.item as MaintenanceNotice;
setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at)));
setMaintenanceHasMore(false);
}
setEditingMaintenance(null);
setMaintenanceMessage(editing ? "Maintenance notice updated." : "Maintenance notice published.");
setMaintenanceMessage(refreshed
? (editing ? "Maintenance notice updated." : "Maintenance notice published.")
: "Saved, but the list could not refresh. Reload this page before loading older notices.");
} else {
const item = result.item as EditorialPost;
setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)));
if (!refreshed) {
setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)));
setPostsHaveMore(false);
}
setEditingPost(null);
setPostMessage(item.status === "published" ? "Field Notes article published." : "Draft saved.");
setPostMessage(refreshed
? (item.status === "published" ? "Field Notes article published." : "Draft saved.")
: "Saved, but the list could not refresh. Reload this page before loading older articles.");
}
form.reset();
} catch (error) {
const message = error instanceof Error ? error.message : "The update could not be saved.";
kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message);

Check warning on line 141 in app/operator/communications/editor.tsx

View workflow job for this annotation

GitHub Actions / validate

Expected an assignment or function call and instead saw an expression
} finally {
setBusy(false);
}
Expand All @@ -77,7 +164,7 @@
<p role="status" aria-live="polite">{maintenanceMessage}</p>
</form>
<h3>Recent notices</h3>
{maintenance.length ? <ul>{maintenance.slice(0, 12).map(item => <li key={item.id} style={{ margin: "12px 0" }}><strong>{item.title}</strong> · {item.status} · {new Date(item.starts_at).toLocaleString()} <button type="button" onClick={() => setEditingMaintenance(item)}>Edit</button></li>)}</ul> : <p>No notices yet.</p>}
{maintenance.length ? <ul>{maintenance.slice(0, maintenanceVisible).map(item => <li key={item.id} style={{ margin: "12px 0" }}><strong>{item.title}</strong> · {item.status} · {new Date(item.starts_at).toLocaleString()} <button type="button" onClick={() => setEditingMaintenance(item)}>Edit</button></li>)}</ul> : <p>No notices yet.</p>}{maintenance.length > maintenanceVisible || maintenanceHasMore ? <button type="button" disabled={loadingMore !== null} onClick={() => void showMore("maintenance")}>{loadingMore === "maintenance" ? "Loading…" : maintenanceVisible < maintenance.length ? "Show more notices" : "Load older notices"}</button> : null}
</section>

<section className="settings-card">
Expand All @@ -98,7 +185,7 @@
<p role="status" aria-live="polite">{postMessage}</p>
</form>
<h3>Recent articles</h3>
{posts.length ? <ul>{posts.slice(0, 12).map(item => <li key={item.id} style={{ margin: "12px 0" }}><strong>{item.title}</strong> · {item.status} <button type="button" onClick={() => setEditingPost(item)}>Edit</button></li>)}</ul> : <p>No articles yet.</p>}
{posts.length ? <ul>{posts.slice(0, postsVisible).map(item => <li key={item.id} style={{ margin: "12px 0" }}><strong>{item.title}</strong> · {item.status} <button type="button" onClick={() => setEditingPost(item)}>Edit</button></li>)}</ul> : <p>No articles yet.</p>}{posts.length > postsVisible || postsHaveMore ? <button type="button" disabled={loadingMore !== null} onClick={() => void showMore("blog")}>{loadingMore === "blog" ? "Loading…" : postsVisible < posts.length ? "Show more articles" : "Load older articles"}</button> : null}
</section>
</div>;
}
2 changes: 1 addition & 1 deletion app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
{params.setup ? <p className="action-notice">Your account is ready. Choose a 4–8 character username to finish setting up your Tidelight profile.</p> : null}{params.updated ? <p className="action-notice">Your preference has been saved.</p> : null}{params.error ? <p className="action-error">We couldn’t save that change. Try again.</p> : null}
<div className="settings-layout">
<section className="settings-card language-card" id="account-preferences"><div className="settings-card-head"><span className="settings-icon mint">文</span><div><span className="eyebrow small-eyebrow">LANGUAGE / 语言</span><h2>Choose your language.</h2></div></div><p>Your language choice is saved on this device and used throughout Tidelight.</p><LanguagePicker /></section>
<section className="settings-card theme-card" id="account-appearance"><div className="settings-card-head"><span className="settings-icon">◐</span><div><span className="eyebrow small-eyebrow">APPEARANCE</span><h2>Your desk, your light.</h2></div></div><p>Choose Dark or White. Dark is the default theme.</p><ThemePicker /></section>
<section className="settings-card theme-card" id="account-appearance"><div className="settings-card-head"><span className="settings-icon">◐</span><div><span className="eyebrow small-eyebrow">APPEARANCE</span><h2>Your desk, your light.</h2></div></div><p>Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.</p><ThemePicker /></section>
<section className="settings-card account-card" id="account-profile"><div className="settings-card-head"><span className="settings-icon">◉</span><div><span className="eyebrow small-eyebrow">ACCOUNT</span><h2>Your workspace</h2></div></div>
{user ? <><div className="account-identity"><div className="avatar large-avatar">{communityProfile?.avatar_url ? <Image src={communityProfile.avatar_url} alt="" width={48} height={48} unoptimized /> : (communityProfile?.display_name ?? user.email ?? "T").slice(0, 1).toUpperCase()}</div><div><b>{communityProfile?.display_name ?? user.email}</b><small>@{communityProfile?.handle ?? "set up a community handle"} · sign-in protected</small></div><span className="connected-pill"><i /> PRIVATE</span></div><ProfileEditor userId={user.id} initialName={communityProfile?.display_name ?? user.user_metadata?.full_name ?? user.email?.split("@")[0] ?? "Tidelight member"} initialHandle={communityProfile?.handle ?? user.id.replace(/-/g, "").slice(0, 8).toLowerCase()} initialAvatar={communityProfile?.avatar_url ?? user.user_metadata?.avatar_url ?? ""} profileExists={Boolean(communityProfile)} handleChangedAt={communityProfile?.handle_changed_at ?? null} /><div className="settings-divider" /><div className="preference-row"><div><b>Research lens</b><small>This preference is saved to your account.</small></div><form action={updateRiskProfile} className="preference-form"><select name="risk_profile" defaultValue={preferences?.risk_profile ?? "balanced"} aria-label="Research lens"><option value="conservative">Conservative</option><option value="balanced">Balanced</option><option value="growth">Growth</option></select><button type="submit">Save</button></form></div></> : <div className="settings-empty"><p>You’re browsing as a guest. Sign in to save preferences and keep your research private across sessions.</p><Link className="primary-link" href="/login">Sign in <span>↗</span></Link></div>}
</section>
Expand Down
Loading
Loading