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
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Why is this change needed?
## Testing

- [ ] `npm run build`
- [ ] `npm run typecheck`
- [ ] Manual check (screenshots optional)

## Security / privacy
Expand Down
99 changes: 82 additions & 17 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import countryCodeList from 'flagpack-core/countryCodeList.json'
import BeatmapCover from '../components/BeatmapCover.vue'
import HitErrorBar from '../components/HitErrorBar.vue'
Expand Down Expand Up @@ -319,7 +319,7 @@ const modBadgeClass = (mod: string) => {

const rankHistory = computed(() => user.value.rankHistory || [])

const chartRef = ref<HTMLElement | null>(null)
const chartRef = ref<SVGSVGElement | null>(null)
const graphHover = ref<{
index: number
xPercent: number
Expand Down Expand Up @@ -375,6 +375,42 @@ const tabs = [

const activeTab = ref<'overview' | 'top' | 'history' | 'deep'>('overview')

// Map URL hash to a tab id. Supports '#deep-stats' and '#deep'.
const mapHashToTab = (hash: string | null) => {
if (!hash) return null
const h = hash.startsWith('#') ? hash.slice(1) : hash
if (h === 'deep' || h === 'deep-stats') return 'deep'
if (h === 'overview') return 'overview'
if (h === 'top') return 'top'
if (h === 'history') return 'history'
return null
}

const setTabFromHash = () => {
const t = mapHashToTab(typeof window !== 'undefined' ? window.location.hash : null)
if (t) activeTab.value = t
}

onMounted(() => {
// initialize from the fragment when the component mounts
setTabFromHash()
window.addEventListener('hashchange', setTabFromHash)
})

onBeforeUnmount(() => {
window.removeEventListener('hashchange', setTabFromHash)
})

// keep the URL fragment in sync with the active tab
watch(activeTab, (val) => {
const frag = val === 'deep' ? 'deep-stats' : val
try {
history.replaceState(null, '', `#${frag}`)
} catch (e) {
if (typeof window !== 'undefined') window.location.hash = `#${frag}`
}
})

const deepScores = computed(() => {
const pool = [
...filteredRecent.value,
Expand Down Expand Up @@ -608,10 +644,38 @@ const onGraphMove = (event: MouseEvent) => {
const firstPoint = meta.points[0]
if (!firstPoint) return

const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
const targetX = xRatio * meta.width
// Map client coordinates into SVG coordinate space for accurate alignment.
let targetX = 0
const svg = chartRef.value
if (svg && typeof svg.getScreenCTM === 'function') {
try {
const pt = (svg.createSVGPoint ? svg.createSVGPoint() : (new DOMPoint() as any)) as any
pt.x = event.clientX
pt.y = event.clientY
const ctm = svg.getScreenCTM()
if (ctm) {
const inv = ctm.inverse()
const transformed = pt.matrixTransform(inv)
targetX = transformed.x
} else {
// fallback to bounding rect ratio
Comment on lines +652 to +661

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple type assertions to 'any' are used here which bypasses TypeScript's type safety. The code attempts to support both deprecated SVG method (createSVGPoint) and the modern DOMPoint API, but the excessive use of 'as any' makes it harder to catch type errors. Consider defining a proper type for the point object or using conditional type checks to handle the different APIs more safely.

Suggested change
const pt = (svg.createSVGPoint ? svg.createSVGPoint() : (new DOMPoint() as any)) as any
pt.x = event.clientX
pt.y = event.clientY
const ctm = svg.getScreenCTM()
if (ctm) {
const inv = ctm.inverse()
const transformed = pt.matrixTransform(inv)
targetX = transformed.x
} else {
// fallback to bounding rect ratio
type MatrixLike = { inverse(): MatrixLike }
type SvgPointLike = {
x: number
y: number
matrixTransform(matrix: MatrixLike): { x: number; y: number }
}
const svgElement = svg as SVGSVGElement & {
createSVGPoint?: () => SvgPointLike
}
let pt: SvgPointLike | null = null
if (typeof svgElement.createSVGPoint === 'function') {
pt = svgElement.createSVGPoint()
} else if (typeof DOMPoint !== 'undefined') {
pt = new DOMPoint() as unknown as SvgPointLike
}
if (pt) {
pt.x = event.clientX
pt.y = event.clientY
const ctm = svg.getScreenCTM()
if (ctm) {
const inv = (ctm as unknown as MatrixLike).inverse()
const transformed = pt.matrixTransform(inv)
targetX = transformed.x
} else {
// fallback to bounding rect ratio
const rect = svg.getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
} else {
// If no suitable point implementation is available, fall back to bounding rect ratio

Copilot uses AI. Check for mistakes.
const rect = svg.getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
} catch (e) {
const rect = svg.getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
} else {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const xPx = Math.min(Math.max(event.clientX - rect.left, 0), rect.width)
const xRatio = rect.width ? xPx / rect.width : 0
targetX = xRatio * meta.width
}
Comment on lines +661 to +678

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated fallback logic. The same bounding rect calculation code appears in three places (lines 662-665, 668-671, and 674-677). Consider extracting this into a helper function to reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

let nearest = firstPoint
let minDist = Math.abs(firstPoint.x - targetX)
Expand Down Expand Up @@ -763,24 +827,21 @@ const onGraphLeave = () => {
</div>

<div class="flex flex-row flex-wrap gap-2">
<div
<div

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation. This line has excessive leading whitespace (appears to be 14 spaces) compared to the surrounding code which uses proper indentation. The opening div tag should be aligned with the surrounding elements at the same nesting level.

Copilot uses AI. Check for mistakes.
v-for="badge in gradeBadges"
:key="badge.label"
class="silky-in rounded-full px-3 py-1 text-xs font-slim transition-all duration-500 ease-out"
:class="[
badge.tone === 'badge-ss' ? 'bg-white text-black border border-white' : '',
badge.tone === 'badge-s' ? 'bg-white text-black border border-white' : '',
badge.tone === 'badge-a' ? 'border border-white/50 text-white' : '',
badge.tone === 'badge-s' ? '' : '',
badge.tone === 'badge-ss' ? '' : '',
:class="[

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation. This line has excessive leading whitespace compared to the surrounding code. The :class attribute should align with the other attributes or follow the project's indentation standard.

Copilot uses AI. Check for mistakes.
badge.tone === 'badge-ss' ? 'bg-white/6 text-white ring-2 ring-amber-200/20 shadow-[0_0_10px_rgba(255,255,255,0.06)]' : '',
badge.tone === 'badge-s' ? 'bg-white/6 text-white ring-2 ring-white/10 shadow-[0_0_8px_rgba(255,255,255,0.04)]' : '',
badge.label === 'F' ? 'line-through opacity-60 border border-white/20 text-white' : '',
!['badge-ss','badge-s','badge-a'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''
!['badge-ss','badge-s'].includes(badge.tone) && badge.label !== 'F' ? 'border border-white/30 text-white' : ''
]"
>
<span class="inline-flex items-baseline gap-1 leading-none">
<span class="text-sm font-semibold">{{ badge.label }}</span>
<span aria-hidden="true">·</span>
<span class="text-sm font-normal">{{ badge.value.toLocaleString() }}</span>
<span class="text-sm font-light text-zinc-300">{{ badge.value.toLocaleString() }}</span>
</span>
</div>
</div>
Expand Down Expand Up @@ -1086,7 +1147,7 @@ const onGraphLeave = () => {
:key="ev.id"
class="relative mb-5 last:mb-0"
>
<span class="absolute -left-1.5 mt-0.5 h-3 w-3 rounded-full border border-white bg-black" />
<span class="absolute -left-4 top-1/2 -translate-y-1/2 h-3 w-3 rounded-full border border-white bg-black" />
<div class="rounded-xl border border-white/10 bg-white/5 px-3 py-2 backdrop-blur">
<p class="text-sm text-white">{{ ev.text }}</p>
<p class="text-xs text-zinc-500">{{ ev.created_at ? new Date(ev.created_at).toISOString().slice(0, 10) : '' }}</p>
Expand All @@ -1110,7 +1171,11 @@ const onGraphLeave = () => {
<div
v-for="score in deepScores"
:key="(score as any).beatmap?.checksum || (score as any).beatmap_md5 || score.id"
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/40 p-4 backdrop-blur transition-all duration-500 ease-out hover:-translate-y-px hover:border-white/30"
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/40 p-4 backdrop-blur transition-all duration-500 ease-out hover:-translate-y-px hover:border-white/30 cursor-pointer"
role="button"
tabindex="0"
@click="score.deep_stats && openDetail(score)"
@keyup.enter="score.deep_stats && openDetail(score)"
>
<div class="absolute inset-0 opacity-20">
<BeatmapCover
Expand Down
4 changes: 2 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export default defineNuxtConfig({
{ name: 'viewport', content: 'width=device-width, initial-scale=1, user-scalable=no' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' },
{ rel: 'apple-touch-icon', href: '/icon.png' }
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/favicon.ico' }
]
}
},
Expand Down