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}`

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.

In the catch block fallback, setting window.location.hash directly will trigger the hashchange event listener, which could cause the setTabFromHash function to run again. While this likely won't cause an infinite loop due to the guard condition in setTabFromHash, it creates an unnecessary re-execution. Consider adding a flag to temporarily disable the hashchange listener during programmatic updates, or simply remove the fallback since history.replaceState has broad browser support.

Suggested change
if (typeof window !== 'undefined') window.location.hash = `#${frag}`
// Swallow errors from history.replaceState; avoid hash fallback to
// prevent triggering the hashchange listener unnecessarily.

Copilot uses AI. Check for mistakes.
}
})

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
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
}

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 extra indentation (14 spaces) compared to the surrounding code. The opening div tag should align with the other elements in the flex container.

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 extra indentation (14 spaces) compared to the parent element. The :class attribute should align with the class attribute above it.

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' },

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.

The MIME type 'image/x-icon' is outdated. Modern browsers and standards recommend using 'image/png' for PNG favicons or leaving the type attribute unspecified for .ico files, as browsers will automatically detect the correct type. The .ico extension suggests this is an ICO file, so the type attribute should either be removed or changed to the non-standard but commonly used 'image/vnd.microsoft.icon'.

Suggested change
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'icon', href: '/favicon.ico' },

Copilot uses AI. Check for mistakes.
{ rel: 'apple-touch-icon', href: '/favicon.ico' }
]
}
},
Expand Down