mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-15 00:04:58 +02:00
* docs(timezone): add scheduling/viewing timezone architecture RFC * feat(db): replace daily rollups with task_usage_hourly, add user.timezone Migrations 100-104: add "user".timezone (Viewing tz), build the UTC hourly task_usage_hourly rollup with its pipeline, drop the legacy task_usage_daily / task_usage_dashboard_daily pipelines, and drop the agent_runtime.timezone column. Report queries now slice day boundaries at read time by the caller-supplied @tz instead of materialising in a fixed tz. Regenerate sqlc. * feat(server): add task_usage_hourly backfill command Replace the two legacy backfill commands (daily / dashboard_daily) with a single backfill_task_usage_hourly that loads historical task_usage into the new UTC hourly rollup, sliced per workspace. * refactor(server): resolve viewing timezone in report handlers Report handlers resolve the Viewing tz per request (?tz query param, then user.timezone, then UTC) and pass it to the hourly-rollup queries. Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup dual paths, remove the /api/usage endpoints, and stop the daemon from reporting and the runtime handler from accepting host timezone. * refactor(core): switch report queries to viewing timezone API client and dashboard/runtime queries send ?tz with each report request, the user schema/types carry the new timezone field, and the runtime timezone field/mutation is removed. * feat(views): add viewing timezone preference and UI Add the useViewingTimezone hook and a Timezone setting in Preferences; report charts and the dashboard week boundary follow the viewer tz. Remove the runtime detail timezone editor and its locale strings. * fix(test): update fixtures and stabilize tests for timezone refactor The timezone architecture refactor changed several types without updating dependent test code: - RuntimeDevice no longer has a timezone field — drop it from the create-agent-dialog runtime fixture. - User now requires a timezone field — add it to the apps/web mockUser fixture. - The PreferencesTab timezone tests asserted on the async save handler (PATCH then store update) with a bare expect, racing the mutation's settle callback, and timed out querying the Select's ~600-option IANA list on a loaded CI runner. Wrap the assertions in waitFor and extend the timeout for those three tests. * docs(timezone): document self-host migration order and trigger invariant Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package comment: applying migrations 100-104 in a single migrate-up drops the legacy daily rollups before the hourly backfill runs, leaving dashboards empty until cron catches up. Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id must be added to the trigger's OF list if it ever becomes mutable, otherwise dirty buckets for the old agent_id are silently missed. * style(runtimes): drop trailing blank line in runtime-detail
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@multica/ui/components/ui/select";
|
|
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import {
|
|
DEFAULT_LOCALE,
|
|
SUPPORTED_LOCALES,
|
|
type SupportedLocale,
|
|
} from "@multica/core/i18n";
|
|
import { useLocaleAdapter } from "@multica/core/i18n/react";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { api } from "@multica/core/api";
|
|
import { browserTimezone, timezoneOptions } from "../../common/timezone-select";
|
|
import { useT } from "../../i18n";
|
|
|
|
const LIGHT_COLORS = {
|
|
titleBar: "#e8e8e8",
|
|
content: "#ffffff",
|
|
sidebar: "#f4f4f5",
|
|
bar: "#e4e4e7",
|
|
barMuted: "#d4d4d8",
|
|
};
|
|
|
|
const DARK_COLORS = {
|
|
titleBar: "#333338",
|
|
content: "#27272a",
|
|
sidebar: "#1e1e21",
|
|
bar: "#3f3f46",
|
|
barMuted: "#52525b",
|
|
};
|
|
|
|
function WindowMockup({
|
|
variant,
|
|
className,
|
|
}: {
|
|
variant: "light" | "dark";
|
|
className?: string;
|
|
}) {
|
|
const colors = variant === "light" ? LIGHT_COLORS : DARK_COLORS;
|
|
|
|
return (
|
|
<div className={cn("flex h-full w-full flex-col", className)}>
|
|
{/* Title bar */}
|
|
<div
|
|
className="flex items-center gap-[3px] px-2 py-1.5"
|
|
style={{ backgroundColor: colors.titleBar }}
|
|
>
|
|
<span className="size-[6px] rounded-full bg-[#ff5f57]" />
|
|
<span className="size-[6px] rounded-full bg-[#febc2e]" />
|
|
<span className="size-[6px] rounded-full bg-[#28c840]" />
|
|
</div>
|
|
{/* Content area */}
|
|
<div
|
|
className="flex flex-1"
|
|
style={{ backgroundColor: colors.content }}
|
|
>
|
|
{/* Sidebar */}
|
|
<div
|
|
className="w-[30%] space-y-1 p-2"
|
|
style={{ backgroundColor: colors.sidebar }}
|
|
>
|
|
<div
|
|
className="h-1 w-3/4 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
<div
|
|
className="h-1 w-1/2 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
</div>
|
|
{/* Main */}
|
|
<div className="flex-1 space-y-1.5 p-2">
|
|
<div
|
|
className="h-1.5 w-4/5 rounded-full"
|
|
style={{ backgroundColor: colors.bar }}
|
|
/>
|
|
<div
|
|
className="h-1 w-full rounded-full"
|
|
style={{ backgroundColor: colors.barMuted }}
|
|
/>
|
|
<div
|
|
className="h-1 w-3/5 rounded-full"
|
|
style={{ backgroundColor: colors.barMuted }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PreferencesTab() {
|
|
const { theme, setTheme } = useTheme();
|
|
const { t, i18n } = useT("settings");
|
|
const localeAdapter = useLocaleAdapter();
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
// i18next.language can be a region-tagged BCP-47 string (e.g. "en-US",
|
|
// "zh-Hans-CN") returned by intl-localematcher. Normalize to a supported
|
|
// locale before comparing — otherwise the radio shows neither option active.
|
|
const currentLocale: SupportedLocale = SUPPORTED_LOCALES.includes(
|
|
i18n.language as SupportedLocale,
|
|
)
|
|
? (i18n.language as SupportedLocale)
|
|
: DEFAULT_LOCALE;
|
|
|
|
const themeOptions = [
|
|
{ value: "light" as const, label: t(($) => $.preferences.theme.light) },
|
|
{ value: "dark" as const, label: t(($) => $.preferences.theme.dark) },
|
|
{ value: "system" as const, label: t(($) => $.preferences.theme.system) },
|
|
];
|
|
|
|
const languageOptions: { value: SupportedLocale; label: string }[] = [
|
|
{ value: "en", label: t(($) => $.preferences.language.english) },
|
|
{ value: "zh-Hans", label: t(($) => $.preferences.language.chinese) },
|
|
];
|
|
|
|
// Persist locally → sync to user.language → reload. Reload (vs in-place
|
|
// changeLanguage) avoids hydration mismatch and is the i18next-recommended
|
|
// pattern for App Router.
|
|
//
|
|
// If the cross-device sync (PATCH /api/me) fails, the local cookie is
|
|
// already written so the new locale will take effect after reload — but
|
|
// the user's other devices won't see the change. Surface that explicitly
|
|
// via a toast and delay the reload long enough for the toast to be read,
|
|
// otherwise the failure would be invisible.
|
|
const handleLanguageChange = async (next: SupportedLocale) => {
|
|
if (next === currentLocale) return;
|
|
localeAdapter.persist(next);
|
|
|
|
let syncFailed = false;
|
|
if (user) {
|
|
try {
|
|
await api.updateMe({ language: next });
|
|
} catch {
|
|
syncFailed = true;
|
|
}
|
|
}
|
|
|
|
if (syncFailed) {
|
|
toast.warning(t(($) => $.preferences.language.sync_failed));
|
|
// Give the toast 2.5s of visible time before navigating away.
|
|
setTimeout(() => window.location.reload(), 2500);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">
|
|
{t(($) => $.preferences.theme.title)}
|
|
</h2>
|
|
<div className="flex gap-6" role="radiogroup">
|
|
{themeOptions.map((opt) => {
|
|
const active = theme === opt.value;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => setTheme(opt.value)}
|
|
className="group flex flex-col items-center gap-2"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"aspect-[4/3] w-36 overflow-hidden rounded-lg ring-1 transition-all",
|
|
active
|
|
? "ring-2 ring-brand"
|
|
: "ring-border hover:ring-2 hover:ring-border"
|
|
)}
|
|
>
|
|
{opt.value === "system" ? (
|
|
<div className="relative h-full w-full">
|
|
<WindowMockup
|
|
variant="light"
|
|
className="absolute inset-0"
|
|
/>
|
|
<WindowMockup
|
|
variant="dark"
|
|
className="absolute inset-0 [clip-path:inset(0_0_0_50%)]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<WindowMockup variant={opt.value} />
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-sm transition-colors",
|
|
active
|
|
? "font-medium text-foreground"
|
|
: "text-muted-foreground"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">
|
|
{t(($) => $.preferences.language.title)}
|
|
</h2>
|
|
<div className="flex gap-3" role="radiogroup">
|
|
{languageOptions.map((opt) => {
|
|
const active = currentLocale === opt.value;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => handleLanguageChange(opt.value)}
|
|
className={cn(
|
|
"rounded-md border px-4 py-2 text-sm transition-colors",
|
|
active
|
|
? "border-brand bg-brand/10 font-medium text-foreground"
|
|
: "border-border text-muted-foreground hover:border-foreground/30"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<TimezoneSection />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Base UI rejects "" as a SelectItem value, so route the "no preference"
|
|
// state through this sentinel and translate at the wire boundary.
|
|
const BROWSER_TZ_VALUE = "__browser__";
|
|
|
|
function TimezoneSection() {
|
|
const { t } = useT("settings");
|
|
const user = useAuthStore((s) => s.user);
|
|
const setUser = useAuthStore((s) => s.setUser);
|
|
const stored = user?.timezone ?? null;
|
|
const browser = browserTimezone();
|
|
const value = stored ?? BROWSER_TZ_VALUE;
|
|
|
|
// Full IANA list (from timezoneOptions in common/timezone-select) so a
|
|
// user needing a non-curated zone isn't stuck with ~18 common ones.
|
|
// Memoized — timezoneOptions enumerates ~600 IANA zones per call.
|
|
const options = useMemo(
|
|
() => timezoneOptions(stored ?? browser),
|
|
[stored, browser],
|
|
);
|
|
|
|
const handleChange = async (next: string) => {
|
|
if (next === value) return;
|
|
const payload = next === BROWSER_TZ_VALUE ? "" : next;
|
|
try {
|
|
const updated = await api.updateMe({ timezone: payload });
|
|
setUser(updated);
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.preferences.timezone.sync_failed),
|
|
);
|
|
}
|
|
};
|
|
|
|
const formatTZLabel = (tz: string) => {
|
|
if (tz === BROWSER_TZ_VALUE) {
|
|
return `${browser}${t(($) => $.preferences.timezone.browser_suffix)}`;
|
|
}
|
|
return tz;
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-2">
|
|
<h2 className="text-sm font-semibold">
|
|
{t(($) => $.preferences.timezone.title)}
|
|
</h2>
|
|
<Select
|
|
value={value}
|
|
onValueChange={(next) => {
|
|
if (next) handleChange(next);
|
|
}}
|
|
>
|
|
<SelectTrigger size="sm" className="w-72 rounded-md font-mono text-xs">
|
|
<SelectValue>{formatTZLabel(value)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent align="start" className="max-h-72">
|
|
<SelectItem value={BROWSER_TZ_VALUE} className="font-mono text-xs">
|
|
{formatTZLabel(BROWSER_TZ_VALUE)}
|
|
</SelectItem>
|
|
{options.map((tz) => (
|
|
<SelectItem key={tz} value={tz} className="font-mono text-xs">
|
|
{formatTZLabel(tz)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[11px] leading-snug text-muted-foreground">
|
|
{t(($) => $.preferences.timezone.hint)}
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|