refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
@@ -0,0 +1,101 @@
"use client"
import { EChartWrapper, getDeviceColor } from "./EChartWrapper"
interface BatteryLifePoint {
id: string
hardwareSlug: string
tdpWatts: number
estimatedBatteryHours: number
wattHours: number | null
tdpMax: number | null
estimatedAtMaxTdpMin: number | null
}
interface BatteryLifeChartProps {
data: BatteryLifePoint[]
deviceNames?: Record<string, string>
}
export function BatteryLifeChart({ data, deviceNames }: BatteryLifeChartProps) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No battery data available</div>
}
// Group by device for separate series
const deviceGroups = new Map<string, BatteryLifePoint[]>()
for (const point of data) {
const existing = deviceGroups.get(point.hardwareSlug) || []
existing.push(point)
deviceGroups.set(point.hardwareSlug, existing)
}
// Build trend lines: for each device, compute wattHours / tdp = hours for a range of TDPs
const series: Array<Record<string, unknown>> = []
let seriesIdx = 0
for (const [slug, points] of deviceGroups.entries()) {
const color = getDeviceColor(seriesIdx)
const name = deviceNames?.[slug] || slug
// Scatter points: TDP vs battery hours
series.push({
name,
type: "scatter" as const,
data: points.map((p) => [p.tdpWatts, p.estimatedBatteryHours]),
itemStyle: { color },
symbolSize: 10,
})
// Trend line: compute theoretical curve using average wattHours for this device
const avgWh = points.reduce((sum, p) => sum + (p.wattHours ?? 0), 0) / points.length
if (avgWh > 0) {
const tdpRange = [2, 5, 8, 10, 12, 15, 18, 20, 25, 30].filter(
(tdp) => tdp <= (points[0].tdpMax ?? 30),
)
series.push({
name: `${name} (est.)`,
type: "line" as const,
data: tdpRange.map((tdp) => [tdp, Math.round((avgWh / tdp) * 10) / 10]),
lineStyle: { color, type: "dashed" as const, width: 1 },
symbol: "none",
silent: true,
})
}
seriesIdx++
}
const option = {
tooltip: {
trigger: "item" as const,
formatter: (params: unknown) => {
const p = params as { seriesName?: string; value?: [number, number] }
if (!p.seriesName || p.seriesName.includes("(est.)")) return ""
return `${p.seriesName}<br/>TDP: ${p.value?.[0]}W<br/>Battery: ~${p.value?.[1]}h`
},
},
legend: {
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: {
type: "value" as const,
name: "TDP (W)",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "Battery (h)",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series,
}
return <EChartWrapper option={option} height={300} />
}
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface DeviceEntry {
hardwareSlug: string
hardwareName: string
count: number
}
export function DeviceDonut({
data,
className,
}: {
data: DeviceEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const total = data.reduce((sum, d) => sum + d.count, 0)
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: "{b}: {c} ({d}%)",
},
series: [
{
type: "pie",
radius: ["50%", "75%"],
center: ["50%", "55%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: "#100b14",
borderWidth: 2,
},
label: {
show: true,
position: "center",
formatter: `{total|${total}}\n{label|entries}`,
rich: {
total: {
fontSize: 22,
fontWeight: "bold",
color: CHART_THEME.text,
lineHeight: 30,
},
label: {
fontSize: 11,
color: CHART_THEME.textMuted,
},
},
},
emphasis: {
label: { show: true },
},
data: data.map((d, idx) => ({
name: d.hardwareName,
value: d.count,
itemStyle: { color: getDeviceColor(idx) },
})),
},
],
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,67 @@
"use client"
import { useRef, useCallback } from "react"
import ReactECharts from "echarts-for-react"
import type { EChartsOption } from "echarts"
// Project theme colors matching globals.css
export const CHART_THEME = {
bg: "transparent",
text: "#ebe4f1",
textMuted: "#6b5a7d",
textSubtle: "#4a3a5c",
border: "#3d2d52",
primary: "#eb3779",
secondary: "#571b8b",
accent: "#fb793c",
success: "#22c55e",
info: "#3b82f6",
warning: "#f59e0b",
// Device-specific colors
deviceColors: [
"#eb3779", // primary (OLED)
"#571b8b", // secondary (LCD)
"#fb793c", // accent (Steam Machine)
"#22c55e",
"#3b82f6",
"#f59e0b",
"#a78bfa",
"#ec4899",
],
}
export function getDeviceColor(index: number): string {
return CHART_THEME.deviceColors[index % CHART_THEME.deviceColors.length]
}
export function EChartWrapper({
option,
height = 300,
className = "",
}: {
option: EChartsOption
height?: number
className?: string
}) {
const chartRef = useRef<ReactECharts>(null)
const onEvents = useCallback(
() => ({
// Placeholder for future event handlers
}),
[],
)
return (
<div className={className} style={{ height }}>
<ReactECharts
ref={chartRef}
option={option}
style={{ height: "100%", width: "100%" }}
opts={{ renderer: "canvas" }}
onEvents={onEvents()}
theme={undefined}
/>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface BoxplotEntry {
hardwareSlug: string
hardwareName: string
min: number
q1: number
median: number
q3: number
max: number
}
export function FpsBoxplot({
data,
className,
}: {
data: BoxplotEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const categories = data.map((d) => d.hardwareName)
const boxData = data.map((d) => [d.min, d.q1, d.median, d.q3, d.max])
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: (params) => {
const d = (params as { data: number[] }).data
if (!Array.isArray(d)) return ""
return `Min: ${d[0]}<br/>Q1: ${d[1]}<br/>Median: ${d[2]}<br/>Q3: ${d[3]}<br/>Max: ${d[4]}`
},
},
grid: { top: 16, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: categories,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series: [
{
type: "boxplot",
data: boxData,
itemStyle: {
color: CHART_THEME.primary + "20",
borderColor: CHART_THEME.primary,
borderWidth: 2,
},
emphasis: {
itemStyle: {
borderColor: CHART_THEME.accent,
borderWidth: 3,
},
},
},
],
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface RangeEntry {
id: string
hardwareSlug: string
fpsLow: number
fpsAvg: number
fpsHigh: number
isRawPerformer: boolean
}
export function FpsRangeChart({
data,
className,
}: {
data: RangeEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const devices = [...new Set(data.map((d) => d.hardwareSlug))]
const sorted = [...data].sort((a, b) => b.fpsAvg - a.fpsAvg)
const labels = sorted.map((_, i) => `#${i + 1}`)
const series = devices.map((device, idx) => ({
name: device.replace(/-/g, " "),
type: "bar" as const,
stack: "range",
data: sorted.map((entry) => {
if (entry.hardwareSlug !== device) return 0
return entry.fpsHigh - entry.fpsLow
}),
itemStyle: {
color: getDeviceColor(idx),
borderRadius: [2, 2, 0, 0],
},
barWidth: "60%",
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: labels,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "FPS Range",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,78 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface HistoricalEntry {
period: string
entries: Array<{
hardwareSlug: string
avgFps: number
count: number
}>
}
export function HistoricalAreaChart({
data,
className,
}: {
data: HistoricalEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const periods = data.map((d) => d.period)
const deviceSlugs = [
...new Set(data.flatMap((d) => d.entries.map((e) => e.hardwareSlug))),
]
const series = deviceSlugs.map((slug, idx) => ({
name: slug.replace(/-/g, " "),
type: "line" as const,
stack: "total",
areaStyle: { opacity: 0.3 },
emphasis: { focus: "series" as const },
smooth: true,
data: periods.map((period) => {
const entry = data
.find((d) => d.period === period)
?.entries.find((e) => e.hardwareSlug === slug)
return entry?.avgFps ?? null
}),
itemStyle: { color: getDeviceColor(idx) },
lineStyle: { color: getDeviceColor(idx) },
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: periods,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "AVG FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={280} className={className} />
}
@@ -0,0 +1,72 @@
"use client"
import { EChartWrapper } from "./EChartWrapper"
interface TierData {
hardwareSlug: string
hardwareName?: string
unplayable: number
playable: number
smooth: number
excellent: number
}
export function PerformanceTierChart({ data }: { data: TierData[] }) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No tier data</div>
}
const labels = data.map((d) => d.hardwareName || d.hardwareSlug)
const option = {
tooltip: {
trigger: "axis" as const,
axisPointer: { type: "shadow" as const },
},
legend: {
data: ["<30 fps", "30-59", "60-119", "≥120"],
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 100, right: 20, top: 40, bottom: 30 },
xAxis: { type: "value" as const, splitLine: { lineStyle: { color: "#333" } } },
yAxis: {
type: "category" as const,
data: labels,
axisLine: { lineStyle: { color: "#555" } },
axisLabel: { color: "#999" },
},
series: [
{
name: "<30 fps",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.unplayable),
itemStyle: { color: "#ef4444" },
},
{
name: "30-59",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.playable),
itemStyle: { color: "#eab308" },
},
{
name: "60-119",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.smooth),
itemStyle: { color: "#22c55e" },
},
{
name: "≥120",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.excellent),
itemStyle: { color: "#3b82f6" },
},
],
}
return <EChartWrapper option={option} height={250} />
}
@@ -0,0 +1,81 @@
"use client"
import { EChartWrapper } from "./EChartWrapper"
interface ScatterPoint {
id: string
hardwareSlug: string
fpsAvg: number
fpsOnePercentLow: number
stabilityRatio: number
}
export function StabilityScatterChart({ data, deviceNames }: { data: ScatterPoint[]; deviceNames?: Record<string, string> }) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No stability data yet</div>
}
// Group by device
const deviceGroups = new Map<string, ScatterPoint[]>()
for (const point of data) {
const existing = deviceGroups.get(point.hardwareSlug) || []
existing.push(point)
deviceGroups.set(point.hardwareSlug, existing)
}
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"]
const series = Array.from(deviceGroups.entries()).map(([slug, points], idx) => ({
name: deviceNames?.[slug] || slug,
type: "scatter" as const,
data: points.map((p) => [p.fpsAvg, p.fpsOnePercentLow]),
itemStyle: { color: colors[idx % colors.length] },
symbolSize: 8,
}))
// Perfect stability line (y = x)
const maxFps = Math.max(...data.map((d) => d.fpsAvg))
const perfectLine = {
name: "Perfect Stability",
type: "line" as const,
data: [
[0, 0],
[maxFps, maxFps],
],
lineStyle: { color: "#555", type: "dashed" as const },
symbol: "none",
silent: true,
}
const option = {
tooltip: {
trigger: "item" as const,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter: (params: any) => {
if (params.seriesName === "Perfect Stability") return ""
return `${params.seriesName}<br/>Avg: ${params.value[0]} fps<br/>1% Low: ${params.value[1]} fps`
},
},
legend: {
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: {
type: "value" as const,
name: "Avg FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "1% Low FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series: [...series, perfectLine],
}
return <EChartWrapper option={option} height={300} />
}
@@ -0,0 +1,99 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface UpscalerStat {
upscalerType: string
upscalerVersion?: string | null
frameGenMethod: string
hardwareSlug: string
avgFps: number
count: number
}
function formatCombo(upscalerType: string, upscalerVersion: string | null | undefined, fg: string): string {
const parts: string[] = []
if (upscalerType !== "none") {
const upscalerLabel = upscalerVersion
? `${upscalerType.toUpperCase()} ${upscalerVersion}`
: upscalerType.toUpperCase()
parts.push(upscalerLabel)
}
if (fg !== "none") {
if (fg === "fsr_fg") parts.push("FSR FG")
else if (fg === "dlss_fg") parts.push("DLSS FG")
else if (fg === "lsfg") parts.push("LSFG")
else parts.push(fg.toUpperCase())
}
return parts.length > 0 ? parts.join(" + ") : "Native"
}
export function UpscalerBarChart({
data,
className,
}: {
data: UpscalerStat[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const combos = [
...new Set(
data.map((d) => formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod)),
),
]
const deviceSlugs = [...new Set(data.map((d) => d.hardwareSlug))]
const series = deviceSlugs.map((slug, idx) => ({
name: slug.replace(/-/g, " "),
type: "bar" as const,
data: combos.map((combo) => {
const match = data.find(
(d) =>
formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod) === combo &&
d.hardwareSlug === slug,
)
return match?.avgFps ?? 0
}),
itemStyle: { color: getDeviceColor(idx), borderRadius: [4, 4, 0, 0] },
barGap: "10%",
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 50, left: 40 },
xAxis: {
type: "category",
data: combos,
axisLabel: {
color: CHART_THEME.textMuted,
fontSize: 10,
rotate: 30,
},
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "AVG FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={280} className={className} />
}