diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index ef31cce..efbca50 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -82,8 +82,8 @@ paths:
schema: { type: string, example: "splits,analyses" }
- name: sort
in: query
- description: 並べ替えの基準。値の無いもの(DNF など)は最後
- schema: { type: string, enum: [date, time, moves, cross, f2l, oll, pll], default: date }
+ description: 並べ替えの基準。tps は段階の手数の合計を実測の時間(+2 を含めない)で割った値。値の無いもの(DNF など)は最後
+ schema: { type: string, enum: [date, time, moves, tps, cross, f2l, oll, pll], default: date }
- { name: order, in: query, description: asc で昇順(既定は降順), schema: { type: string, enum: [asc, desc] } }
responses:
"200":
diff --git a/internal/solves/solves.go b/internal/solves/solves.go
index ef05273..31e36a3 100644
--- a/internal/solves/solves.go
+++ b/internal/solves/solves.go
@@ -349,6 +349,8 @@ var SortKeys = map[string]string{
"date": "solved_at",
"time": "CASE penalty WHEN 'DNF' THEN NULL WHEN '+2' THEN total_ms + 2000 ELSE total_ms END",
"moves": "(SELECT SUM(move_count) FROM solve_splits WHERE solve_id = solves.id)",
+ // 1秒あたりの手数(段階の手数の合計 ÷ 実測の時間。+2 は含めない)
+ "tps": "(SELECT SUM(move_count) * 1000.0 / NULLIF(solves.total_ms, 0) FROM solve_splits WHERE solve_id = solves.id)",
"cross": splitSum("'cross'"),
"f2l": splitSum("'f2l1', 'f2l2', 'f2l3', 'f2l4'"),
"oll": splitSum("'oll'"),
diff --git a/internal/solves/solves_test.go b/internal/solves/solves_test.go
index a503334..8884144 100644
--- a/internal/solves/solves_test.go
+++ b/internal/solves/solves_test.go
@@ -194,6 +194,7 @@ func TestListSort(t *testing.T) {
{Filter{Sort: "time"}, []int64{4, 2, 1, 3}}, // 降順でも DNF は最後
{Filter{Sort: "moves", Ascending: true}, []int64{2, 1, 4, 3}}, // 段階の無いソルブは最後(新しい順)
{Filter{Sort: "oll", Ascending: true}, []int64{1, 2, 4, 3}},
+ {Filter{Sort: "tps"}, []int64{1, 2, 4, 3}}, // 38手/10秒 > 16手/9秒(+2 は含めない)、段階の無いソルブは最後
{Filter{Sort: "time", Ascending: true, Limit: 2, Offset: 1}, []int64{2, 4}},
} {
if got := ids(tc.f); !slices.Equal(got, tc.want) {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index b93027b..2514c0d 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -14,7 +14,10 @@ import { TimerPage } from './pages/TimerPage'
import { SettingsProvider } from './settings/SettingsProvider'
import { setNavCollapsed, useNavCollapsed } from './ui/prefs'
-/** 左のメニュー。match に当てはまるパスでも選択中にする(解析は統計・履歴・解析の3つのタブをまとめる)。 */
+/**
+ * 左のメニュー。match に当てはまるパスでも選択中にする(解析は履歴・統計・AI解析の3つのタブをまとめ、履歴から開く)。
+ * 中継はヘッダーの LIVE から開く。
+ */
const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?: RegExp }[] = [
{
to: '/',
@@ -27,6 +30,12 @@ const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?:
>
),
},
+ {
+ to: '/history',
+ label: '解析',
+ match: /^\/(stats|history|analyses)(\/|$)/,
+ icon: ,
+ },
{
to: '/practice',
label: '練習',
@@ -38,22 +47,6 @@ const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?:
>
),
},
- {
- to: '/live',
- label: '中継',
- icon: (
- <>
-
-
- >
- ),
- },
- {
- to: '/stats',
- label: '解析',
- match: /^\/(stats|history|analyses)(\/|$)/,
- icon: ,
- },
]
/** 左のメニューを重ねて開く(畳めない)画面幅。CSS の @media と合わせる。 */
@@ -118,6 +111,15 @@ export default function App() {
Cubalyst
+ `live-link ${isActive ? 'active' : ''}`}
+ title="中継:キューブをつないだブラウザのタイマー画面を映す"
+ aria-label="中継"
+ >
+
+ LIVE
+
diff --git a/web/src/api.ts b/web/src/api.ts
index baa7ca5..e1695ff 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -55,7 +55,7 @@ export type Solve = {
export type NewSolve = Omit
/** 一覧の並べ替えの基準。time は +2 込み、DNF と値の無いソルブ(段階が無いなど)はいつも最後。 */
-export type SolveSort = 'date' | 'time' | 'moves' | 'cross' | 'f2l' | 'oll' | 'pll'
+export type SolveSort = 'date' | 'time' | 'moves' | 'tps' | 'cross' | 'f2l' | 'oll' | 'pll'
export type SolveFilter = {
mode?: SolveMode
diff --git a/web/src/components/AnalysisTabs.tsx b/web/src/components/AnalysisTabs.tsx
index d4958de..8134d40 100644
--- a/web/src/components/AnalysisTabs.tsx
+++ b/web/src/components/AnalysisTabs.tsx
@@ -1,12 +1,12 @@
import { NavLink } from 'react-router'
const TABS = [
- { to: '/stats', label: '統計' },
{ to: '/history', label: '履歴' },
+ { to: '/stats', label: '統計' },
{ to: '/analyses', label: 'AI解析' },
]
-/** 「解析」の中のタブ(統計・履歴・AI解析)。 */
+/** 「解析」の中のタブ(履歴・統計・AI解析)。 */
export function AnalysisTabs() {
return (