This commit is contained in:
Marcelo
2026-04-24 15:17:28 +00:00
parent 5d3a2c533f
commit 30513ff73d
9 changed files with 337 additions and 155 deletions

View File

@@ -302,6 +302,133 @@ function toErrorMessage(value: unknown, fallback: string): string {
return fallback;
}
type MachineActivityTimelineProps = {
machineId?: string;
locale: string;
t: (key: string, vars?: Record<string, string | number>) => string;
};
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const timelineHashRef = useRef("");
useEffect(() => {
if (!machineId) return;
let alive = true;
timelineHashRef.current = "";
setTimelineLoading(true);
async function loadTimeline() {
try {
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
const nextTimeline = json as RecapTimelineResponse;
const nextHash = JSON.stringify({
hasData: nextTimeline.hasData,
segments: nextTimeline.segments.map((segment) => ({
type: segment.type,
startMs: segment.startMs,
endMs: segment.endMs,
})),
});
if (timelineHashRef.current === nextHash) return;
timelineHashRef.current = nextHash;
setTimeline(nextTimeline);
} finally {
if (alive) setTimelineLoading(false);
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 30000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machineId]);
const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
const totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]);
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div>
<div className="text-xs text-zinc-400">1h</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
<div key={type} className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
<span>
{type === "production" ? t("recap.timeline.type.production") : null}
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
{type === "idle" ? t("recap.timeline.type.idle") : null}
</span>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
<span>{formatTime(endMs, locale)}</span>
</div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
{!hasData ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")}
</div>
) : (
normalized.map((segment, idx) => {
const widthPct = widths[idx] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
title={title}
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
/>
);
})
)}
</div>
</div>
</div>
);
}
export default function MachineDetailClient() {
const { t, locale } = useI18n();
const { screenlessMode } = useScreenlessMode();
@@ -683,130 +810,6 @@ export default function MachineDetailClient() {
);
}
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [timelineLoading, setTimelineLoading] = useState(true);
const timelineHashRef = useRef("");
useEffect(() => {
if (!machineId) return;
let alive = true;
timelineHashRef.current = "";
setTimeline(null);
setTimelineLoading(true);
async function loadTimeline() {
try {
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
const nextTimeline = json as RecapTimelineResponse;
const nextHash = JSON.stringify({
start: nextTimeline.range?.start ?? "",
end: nextTimeline.range?.end ?? "",
hasData: nextTimeline.hasData,
segments: nextTimeline.segments.map((segment) => ({
type: segment.type,
startMs: segment.startMs,
endMs: segment.endMs,
})),
});
if (timelineHashRef.current === nextHash) return;
timelineHashRef.current = nextHash;
setTimeline(nextTimeline);
} finally {
if (alive) setTimelineLoading(false);
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 30000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machineId]);
const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
const totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]);
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div>
<div className="text-xs text-zinc-400">1h</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
<div key={type} className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
<span>
{type === "production" ? t("recap.timeline.type.production") : null}
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
{type === "idle" ? t("recap.timeline.type.idle") : null}
</span>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
<span>{formatTime(endMs, locale)}</span>
</div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
{!hasData ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")}
</div>
) : (
normalized.map((segment, idx) => {
const widthPct = widths[idx] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
title={title}
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
/>
);
})
)}
</div>
</div>
</div>
);
}
function Modal({
open,
onClose,
@@ -1115,7 +1118,7 @@ export default function MachineDetailClient() {
</div>
<div className="mt-6">
<MachineActivityTimeline machineId={machineId} />
<MachineActivityTimeline machineId={machineId} locale={locale} t={t} />
</div>
{!screenlessMode && (
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -42,7 +42,12 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode;
const requestedRange =
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
const selectedRange = requestedRange;
const shiftAvailable = initialData.range.shiftAvailable ?? true;
const shiftFallbackReason = initialData.range.fallbackReason;
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
const params = new URLSearchParams(searchParams.toString());
@@ -123,7 +128,9 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
<button
key={range}
type="button"
disabled={range === "shift" && !shiftAvailable}
onClick={() => {
if (range === "shift" && !shiftAvailable) return;
if (range === "custom") {
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
return;
@@ -134,7 +141,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
selectedRange === range
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
: "border-white/10 bg-black/40 text-zinc-200"
}`}
} ${range === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
>
{range === "24h" ? t("recap.range.24h") : null}
{range === "shift" ? t("recap.range.shiftCurrent") : null}
@@ -145,6 +152,18 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
</div>
</div>
{!shiftAvailable ? (
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{t("recap.range.shiftUnavailable")}
</div>
) : null}
{shiftFallbackActive ? (
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
</div>
) : null}
{selectedRange === "custom" ? (
<div className="mb-4 flex flex-wrap gap-2 text-sm">
<input
@@ -184,6 +203,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
goodParts={machine.goodParts}
totalStops={Math.round(machine.stopMinutes)}
scrapParts={machine.scrap}
rangeMode={initialData.range.mode}
/>
<div className="mt-4">