Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions internal/solves/solves.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'"),
Expand Down
1 change: 1 addition & 0 deletions internal/solves/solves_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 19 additions & 17 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/',
Expand All @@ -27,6 +30,12 @@ const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?:
</>
),
},
{
to: '/history',
label: '解析',
match: /^\/(stats|history|analyses)(\/|$)/,
icon: <path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />,
},
{
to: '/practice',
label: '練習',
Expand All @@ -38,22 +47,6 @@ const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?:
</>
),
},
{
to: '/live',
label: '中継',
icon: (
<>
<circle cx="12" cy="12" r="2" />
<path d="M8.5 15.5a5 5 0 0 1 0-7M15.5 8.5a5 5 0 0 1 0 7M5.6 18.4a9 9 0 0 1 0-12.8M18.4 5.6a9 9 0 0 1 0 12.8" />
</>
),
},
{
to: '/stats',
label: '解析',
match: /^\/(stats|history|analyses)(\/|$)/,
icon: <path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />,
},
]

/** 左のメニューを重ねて開く(畳めない)画面幅。CSS の @media と合わせる。 */
Expand Down Expand Up @@ -118,6 +111,15 @@ export default function App() {
<Link to="/">Cubalyst</Link>
</h1>
<div className="header-right">
<NavLink
to="/live"
className={({ isActive }) => `live-link ${isActive ? 'active' : ''}`}
title="中継:キューブをつないだブラウザのタイマー画面を映す"
aria-label="中継"
>
<span className="live-dot" aria-hidden="true" />
LIVE
</NavLink>
<CubeConnectionControl />
<SystemMenu />
</div>
Expand Down
2 changes: 1 addition & 1 deletion web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type Solve = {
export type NewSolve = Omit<Solve, 'id'>

/** 一覧の並べ替えの基準。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
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/AnalysisTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="row mode-tabs" aria-label="解析の表示">
Expand Down
46 changes: 34 additions & 12 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1042,22 +1042,10 @@ a:hover {
font-size: 1rem;
}

.history-table td.note {
font-family: var(--font-ui);
font-size: 0.9rem;
}

.history-table td.time {
font-weight: 600;
}

.history-table td.note {
color: var(--mute);
max-width: 16em;
overflow: hidden;
text-overflow: ellipsis;
}

.history-page {
overflow-x: auto;
}
Expand Down Expand Up @@ -1466,6 +1454,40 @@ a.kind-badge:hover {
margin-left: auto;
}

/* 中継(/live)へのリンク。ヘッダーのキューブ名の左 */
.live-link {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border: 1px solid color-mix(in srgb, var(--error) 55%, transparent);
border-radius: 4px;
color: var(--error);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-decoration: none;
white-space: nowrap;
}

.live-link:hover {
background: color-mix(in srgb, var(--error) 10%, transparent);
}

.live-link.active {
background: var(--error);
border-color: var(--error);
color: #fff;
}

.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}

.conn-chip {
display: inline-flex;
align-items: center;
Expand Down
12 changes: 6 additions & 6 deletions web/src/pages/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AnalysisTabs } from '../components/AnalysisTabs'
import { formatDate, KIND_LABELS, MODE_LABELS } from '../format'
import { GROUP_LABELS, groupSplits, type StageGroup } from '../solve/cfop'
import { useSolveList } from '../solve/hooks'
import { effectiveTime, formatTime } from '../solve/stats'
import { effectiveTime, formatTime, formatTps } from '../solve/stats'

const PAGE_SIZE = 50
const GROUPS: StageGroup[] = ['cross', 'f2l', 'oll', 'pll']
Expand All @@ -22,7 +22,7 @@ const DEFAULT_SORT: Sort = { key: 'date', asc: false }

/** 履歴:ソルブ一覧(フィルタ・並べ替え・ページング・選択してexport)。 */
export function HistoryPage() {
const [mode, setMode] = useState<SolveMode | ''>('')
const [mode, setMode] = useState<SolveMode | ''>('full')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [page, setPage] = useState(0)
Expand Down Expand Up @@ -59,9 +59,9 @@ export function HistoryPage() {
set(v)
setPage(0)
}
// 同じ列ならもう一度押すと逆順。別の列は、日時なら新しい順、それ以外は速い(少ない)順から
// 同じ列ならもう一度押すと逆順。別の列は、日時なら新しい順、TPS なら大きい(速い)順、それ以外は速い(少ない)順から
const sortBy = (key: SolveSort) =>
resetPage(setSort)(sort.key === key ? { key, asc: !sort.asc } : { key, asc: key !== 'date' })
resetPage(setSort)(sort.key === key ? { key, asc: !sort.asc } : { key, asc: key !== 'date' && key !== 'tps' })
const sortHeader = (key: SolveSort, label: string) => (
<th aria-sort={sort.key === key ? (sort.asc ? 'ascending' : 'descending') : undefined}>
<button className={`sort-button ${sort.key === key ? 'sorted' : ''}`} onClick={() => sortBy(key)}>
Expand Down Expand Up @@ -119,8 +119,8 @@ export function HistoryPage() {
<Fragment key={g}>{sortHeader(g, GROUP_LABELS[g])}</Fragment>
))}
{sortHeader('moves', '手数')}
{sortHeader('tps', 'TPS')}
<th>解析</th>
<th className="left">メモ</th>
</tr>
</thead>
<tbody>
Expand All @@ -147,10 +147,10 @@ export function HistoryPage() {
<td key={g}>{groups[g] ? formatTime(groups[g].ms) : ''}</td>
))}
<td>{moves || ''}</td>
<td>{moves ? formatTps(moves, s.total_ms) : ''}</td>
<td className="analysis-marks">
<AnalysisMarks solveId={s.id} counts={s.analyses} />
</td>
<td className="left note">{s.note}</td>
</tr>
)
})}
Expand Down
Loading