chore: fix lints and tests
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 53s
CI / Lint & Test (pull_request) Failing after 7m36s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped

This commit is contained in:
2026-01-19 19:51:10 -08:00
parent da566f408e
commit 27f69cb308
11 changed files with 1128 additions and 1003 deletions
@@ -1,23 +1,33 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { fade, fly } from 'svelte/transition'; import { fade, fly } from "svelte/transition";
import { cubicOut } from 'svelte/easing'; import { cubicOut } from "svelte/easing";
import { listen } from '@tauri-apps/api/event'; import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from '$lib/types/achievements'; import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]); let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null); let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false); let showNotification = $state(false);
onMount(async () => { onMount(() => {
const unlisten = await listen<AchievementUnlockedEvent>('achievement:unlocked', (event) => { let unlisten: (() => void) | undefined;
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
return unlisten; const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}); });
function showNext() { function showNext() {
@@ -42,19 +52,33 @@
function getRarityColor(rarity: string): string { function getRarityColor(rarity: string): string {
switch (rarity) { switch (rarity) {
case 'legendary': return 'from-yellow-400 to-orange-500'; case "legendary":
case 'epic': return 'from-purple-400 to-pink-500'; return "from-yellow-400 to-orange-500";
case 'rare': return 'from-blue-400 to-indigo-500'; case "epic":
default: return 'from-green-400 to-emerald-500'; return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
} }
} }
function getAchievementRarity(id: string): string { function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID // Determine rarity based on achievement ID
if (id === 'TokenMaster') return 'legendary'; if (id === "TokenMaster") return "legendary";
if (['CodeMachine', 'Unstoppable'].includes(id)) return 'epic'; if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (['BlossomingCoder', 'CodeWizard', 'MasterBuilder', 'EnduranceChamp', 'DeepDive', 'CreativeCoder'].includes(id)) return 'rare'; if (
return 'common'; [
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
} }
</script> </script>
@@ -67,18 +91,27 @@
<!-- Backdrop with animated gradient border --> <!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden"> <div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border --> <!-- Animated gradient border -->
<div class="absolute inset-0 bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} animate-pulse"></div> <div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content --> <!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm"> <div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button <button
onclick={dismiss} onclick={dismiss}
onkeydown={(e) => e.key === 'Enter' && dismiss()} onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors" class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification" aria-label="Dismiss notification"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg> </svg>
</button> </button>
@@ -89,13 +122,21 @@
<!-- Sparkle animations --> <!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div> <div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"></div> <div
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"></div> class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div> </div>
<!-- Text content --> <!-- Text content -->
<div class="flex-1 min-w-0 pt-1"> <div class="flex-1 min-w-0 pt-1">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"> <h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked! Achievement Unlocked!
</h3> </h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1"> <p class="text-lg font-bold text-[var(--text-primary)] mt-1">
@@ -107,7 +148,11 @@
<!-- Rarity badge --> <!-- Rarity badge -->
<div class="mt-2 inline-flex items-center"> <div class="mt-2 inline-flex items-center">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} text-white capitalize"> <span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)} {getAchievementRarity(currentAchievement.achievement.id)}
</span> </span>
</div> </div>
@@ -116,10 +161,13 @@
<!-- Celebration confetti effect (CSS only) --> <!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg"> <div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _, i} {#each Array(10) as _ (_)}
<div <div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} rounded-full animate-fall" class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
style="left: {Math.random() * 100}%; animation-delay: {Math.random() * 2}s; animation-duration: {2 + Math.random() * 2}s;" getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div> ></div>
{/each} {/each}
</div> </div>
@@ -151,4 +199,4 @@
.animation-delay-400 { .animation-delay-400 {
animation-delay: 400ms; animation-delay: 400ms;
} }
</style> </style>
+80 -37
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from "svelte/transition";
import { quintOut } from 'svelte/easing'; import { quintOut } from "svelte/easing";
import { import {
achievementsStore, achievementsStore,
achievementProgress, achievementProgress,
achievementCategories, achievementCategories,
} from '$lib/stores/achievements'; } from "$lib/stores/achievements";
import type { Achievement } from '$lib/types/achievements'; import type { Achievement } from "$lib/types/achievements";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose?: () => void; onClose?: () => void;
} }
const { isOpen = $bindable(false), onClose } = $props<Props>(); const { isOpen = $bindable(false), onClose }: Props = $props();
let selectedCategory = $state<string | null>(null); let selectedCategory = $state<string | null>(null);
const achievementsState = $derived($achievementsStore); const achievementsState = $derived($achievementsStore);
@@ -21,33 +21,45 @@
function getRarityColor(rarity: string): string { function getRarityColor(rarity: string): string {
switch (rarity) { switch (rarity) {
case 'legendary': return 'text-yellow-500 dark:text-yellow-400'; case "legendary":
case 'epic': return 'text-purple-500 dark:text-purple-400'; return "text-yellow-500 dark:text-yellow-400";
case 'rare': return 'text-blue-500 dark:text-blue-400'; case "epic":
default: return 'text-green-500 dark:text-green-400'; return "text-purple-500 dark:text-purple-400";
case "rare":
return "text-blue-500 dark:text-blue-400";
default:
return "text-green-500 dark:text-green-400";
} }
} }
function getRarityBg(rarity: string): string { function getRarityBg(rarity: string): string {
switch (rarity) { switch (rarity) {
case 'legendary': return 'bg-yellow-500/10'; case "legendary":
case 'epic': return 'bg-purple-500/10'; return "bg-yellow-500/10";
case 'rare': return 'bg-blue-500/10'; case "epic":
default: return 'bg-green-500/10'; return "bg-purple-500/10";
case "rare":
return "bg-blue-500/10";
default:
return "bg-green-500/10";
} }
} }
function formatDate(date: Date | undefined): string { function formatDate(date: Date | undefined): string {
if (!date) return ''; if (!date) return "";
return new Date(date).toLocaleDateString('en-US', { return new Date(date).toLocaleDateString("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric', year: "numeric",
}); });
} }
function getAchievementsForCategory(categoryIds: string[]): Achievement[] { function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
return categoryIds.map(id => achievementsState.achievements[id as keyof typeof achievementsState.achievements]).filter(Boolean); return categoryIds
.map(
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
)
.filter(Boolean);
} }
</script> </script>
@@ -56,7 +68,7 @@
<div <div
class="fixed inset-0 bg-black/50 z-40" class="fixed inset-0 bg-black/50 z-40"
onclick={onClose} onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()} onkeydown={(e) => e.key === "Escape" && onClose?.()}
role="button" role="button"
tabindex="-1" tabindex="-1"
aria-label="Close achievements panel" aria-label="Close achievements panel"
@@ -66,7 +78,7 @@
<div <div
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)] class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
shadow-2xl z-50 flex flex-col" shadow-2xl z-50 flex flex-col"
transition:slide={{ duration: 300, easing: quintOut, axis: 'x' }} transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
> >
<!-- Header --> <!-- Header -->
<div class="p-6 border-b border-[var(--border-color)]"> <div class="p-6 border-b border-[var(--border-color)]">
@@ -74,18 +86,26 @@
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2> <h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
<button <button
onclick={onClose} onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()} onkeydown={(e) => e.key === "Enter" && onClose?.()}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close achievements panel"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg> </svg>
</button> </button>
</div> </div>
<!-- Overall progress --> <!-- Overall progress -->
<div class="mt-4"> <div class="mt-4">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"> <div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
>
<span>{progress.unlocked} / {progress.total} Unlocked</span> <span>{progress.unlocked} / {progress.total} Unlocked</span>
<span>{progress.percentage}%</span> <span>{progress.percentage}%</span>
</div> </div>
@@ -100,14 +120,17 @@
<!-- Categories --> <!-- Categories -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#each achievementCategories as category} {#each achievementCategories as category (category.name)}
{@const achievements = getAchievementsForCategory(category.ids)} {@const achievements = getAchievementsForCategory(category.ids)}
{@const unlockedCount = achievements.filter(a => a.unlocked).length} {@const unlockedCount = achievements.filter((a) => a.unlocked).length}
<div class="border-b border-[var(--border-color)]"> <div class="border-b border-[var(--border-color)]">
<button <button
onclick={() => selectedCategory = selectedCategory === category.name ? null : category.name} onclick={() =>
onkeydown={(e) => e.key === 'Enter' && (selectedCategory = selectedCategory === category.name ? null : category.name)} (selectedCategory = selectedCategory === category.name ? null : category.name)}
onkeydown={(e) =>
e.key === "Enter" &&
(selectedCategory = selectedCategory === category.name ? null : category.name)}
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors" class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -120,12 +143,19 @@
{unlockedCount} / {achievements.length} {unlockedCount} / {achievements.length}
</span> </span>
<svg <svg
class="w-5 h-5 transition-transform {selectedCategory === category.name ? 'rotate-180' : ''}" class="w-5 h-5 transition-transform {selectedCategory === category.name
? 'rotate-180'
: ''}"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg> </svg>
</div> </div>
</div> </div>
@@ -133,9 +163,11 @@
{#if selectedCategory === category.name} {#if selectedCategory === category.name}
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}> <div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
{#each achievements as achievement} {#each achievements as achievement (achievement.id)}
<div <div
class="p-3 rounded-lg border {achievement.unlocked ? 'border-[var(--border-color)] bg-[var(--bg-secondary)]' : 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}" class="p-3 rounded-lg border {achievement.unlocked
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Icon --> <!-- Icon -->
@@ -149,7 +181,11 @@
<h4 class="font-semibold text-[var(--text-primary)]"> <h4 class="font-semibold text-[var(--text-primary)]">
{achievement.name} {achievement.name}
</h4> </h4>
<span class="text-xs px-2 py-0.5 rounded-full {getRarityBg(achievement.rarity)} {getRarityColor(achievement.rarity)} capitalize"> <span
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
achievement.rarity
)} {getRarityColor(achievement.rarity)} capitalize"
>
{achievement.rarity} {achievement.rarity}
</span> </span>
</div> </div>
@@ -171,7 +207,10 @@
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5"> <div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
<div <div
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300" class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.min((achievement.progress / achievement.maxProgress) * 100, 100)}%" style="width: {Math.min(
(achievement.progress / achievement.maxProgress) * 100,
100
)}%"
></div> ></div>
</div> </div>
</div> </div>
@@ -193,8 +232,12 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span> <span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
<div> <div>
<p class="font-semibold text-[var(--text-primary)]">{achievementsState.lastUnlocked.name}</p> <p class="font-semibold text-[var(--text-primary)]">
<p class="text-xs text-gray-500">{formatDate(achievementsState.lastUnlocked.unlockedAt)}</p> {achievementsState.lastUnlocked.name}
</p>
<p class="text-xs text-gray-500">
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -220,4 +263,4 @@
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) { :global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: var(--accent-primary); background: var(--accent-primary);
} }
</style> </style>
+144 -145
View File
@@ -1,169 +1,168 @@
<script lang="ts"> <script lang="ts">
import { formattedStats } from '$lib/stores/stats'; import { formattedStats } from "$lib/stores/stats";
import { fade } from 'svelte/transition'; import { fade } from "svelte/transition";
let showToolsBreakdown = false; let showToolsBreakdown = false;
</script> </script>
<div class="stats-display" transition:fade={{ duration: 200 }}> <div class="stats-display" transition:fade={{ duration: 200 }}>
<div class="stats-row"> <div class="stats-row">
<span class="stat-label">Duration:</span> <span class="stat-label">Duration:</span>
<span class="stat-value">{$formattedStats.sessionDuration}</span> <span class="stat-value">{$formattedStats.sessionDuration}</span>
</div> </div>
<div class="stats-row"> <div class="stats-row">
<span class="stat-label">Messages:</span> <span class="stat-label">Messages:</span>
<span class="stat-value">{$formattedStats.messagesSession}</span> <span class="stat-value">{$formattedStats.messagesSession}</span>
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span> <span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
</div> </div>
<div class="stats-section">
<h3>Tokens & Cost</h3>
<div class="stat-row">
<span class="stat-label">Session:</span>
<span class="stat-value">{$formattedStats.sessionTokens}</span>
<span class="stat-cost">{$formattedStats.sessionCost}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Input:</span>
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Output:</span>
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
</div>
<div class="stat-row stat-highlight">
<span class="stat-label">Total:</span>
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-cost">{$formattedStats.totalCost}</span>
</div>
</div>
<div class="stats-section">
<h3>Activity</h3>
<div class="stat-row">
<span class="stat-label">Code blocks:</span>
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files edited:</span>
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files created:</span>
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
</div>
</div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
<div class="stats-section"> <div class="stats-section">
<h3>Tokens & Cost</h3> <h3 class="tools-header">
<div class="stat-row"> <button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
<span class="stat-label">Session:</span> Tools Used
<span class="stat-value">{$formattedStats.sessionTokens}</span> <span class="toggle-icon">{showToolsBreakdown ? "▼" : "▶"}</span>
<span class="stat-cost">{$formattedStats.sessionCost}</span> </button>
</div> </h3>
<div class="stat-row stat-detail"> {#if showToolsBreakdown}
<span class="stat-label">Input:</span> <div class="tools-breakdown">
<span class="stat-value">{$formattedStats.sessionInputTokens}</span> {#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
</div> <div class="stat-row stat-detail">
<div class="stat-row stat-detail"> <span class="stat-label">{tool}:</span>
<span class="stat-label">Output:</span> <span class="stat-value">{count}</span>
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span> </div>
</div> {/each}
<div class="stat-row stat-highlight">
<span class="stat-label">Total:</span>
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-cost">{$formattedStats.totalCost}</span>
</div> </div>
{/if}
</div> </div>
{/if}
<div class="stats-section"> <div class="model-info">
<h3>Activity</h3> <span class="model-label">Model:</span>
<div class="stat-row"> <span class="model-value">{$formattedStats.model}</span>
<span class="stat-label">Code blocks:</span> </div>
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files edited:</span>
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files created:</span>
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
</div>
</div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button
class="tools-toggle"
onclick={() => showToolsBreakdown = !showToolsBreakdown}
>
Tools Used
<span class="toggle-icon">{showToolsBreakdown ? '▼' : '▶'}</span>
</button>
</h3>
{#if showToolsBreakdown}
<div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count]}
<div class="stat-row stat-detail">
<span class="stat-label">{tool}:</span>
<span class="stat-value">{count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
</div>
</div> </div>
<style> <style>
.stats-display { .stats-display {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow:
} 0 4px 6px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-section { .stats-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.stats-section h3 { .stats-section h3 {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.stat-row { .stat-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.125rem 0; padding: 0.125rem 0;
} }
.stat-detail { .stat-detail {
margin-left: 1rem; margin-left: 1rem;
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.8; opacity: 0.8;
} }
.stat-highlight { .stat-highlight {
font-weight: 600; font-weight: 600;
color: var(--accent-primary); color: var(--accent-primary);
margin-top: 0.25rem; margin-top: 0.25rem;
padding-top: 0.25rem; padding-top: 0.25rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.stat-label { .stat-label {
color: var(--text-secondary, #9ca3af); color: var(--text-secondary, #9ca3af);
} }
.stat-value { .stat-value {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb); color: var(--text-primary, #e5e7eb);
} }
.model-info { .model-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.5rem; padding: 0.5rem;
background: var(--bg-primary); background: var(--bg-primary);
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; font-size: 0.8rem;
} }
.model-label { .model-label {
color: var(--text-secondary, #9ca3af); color: var(--text-secondary, #9ca3af);
font-weight: 600; font-weight: 600;
} }
.model-value { .model-value {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb); color: var(--text-primary, #e5e7eb);
font-size: 0.75rem; font-size: 0.75rem;
} }
</style> </style>
+9 -5
View File
@@ -3,7 +3,7 @@
onToggleAchievements?: () => void; onToggleAchievements?: () => void;
} }
const { onToggleAchievements = () => {} } = $props<Props>(); const { onToggleAchievements = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
@@ -188,14 +188,18 @@
> >
<span class="text-lg">🏆</span> <span class="text-lg">🏆</span>
{#if progress.unlocked > 0} {#if progress.unlocked > 0}
<span class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"> <span
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
>
{progress.unlocked} {progress.unlocked}
</span> </span>
{/if} {/if}
</button> </button>
<button <button
onclick={() => showStats = !showStats} onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats ? 'text-[var(--accent-primary)]' : ''}" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
title="Usage Stats" title="Usage Stats"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -269,7 +273,7 @@
{#if showStats} {#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => showStats = false}></div> <div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50"> <div class="fixed top-14 right-4 z-50">
<StatsDisplay /> <StatsDisplay />
</div> </div>
+3 -1
View File
@@ -84,7 +84,9 @@
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm" class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
> >
{#if lines.length === 0} {#if lines.length === 0}
<div class="terminal-waiting italic">Waiting for Claude... Type a message below to start!</div> <div class="terminal-waiting italic">
Waiting for Claude... Type a message below to start!
</div>
{:else} {:else}
{#each lines as line (line.id)} {#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}"> <div class="terminal-line mb-2 {getLineClass(line.type)}">
+11 -11
View File
@@ -1,19 +1,19 @@
// Achievement sound player using the notification system // Achievement sound player using the notification system
import { soundPlayer } from '$lib/notifications'; import { soundPlayer } from "$lib/notifications";
import { NotificationType } from '$lib/notifications/types'; import { NotificationType } from "$lib/notifications/types";
export function playAchievementSound() { export function playAchievementSound() {
// Use the soundPlayer which respects global notification settings // Use the soundPlayer which respects global notification settings
soundPlayer.play(NotificationType.ACHIEVEMENT); soundPlayer.play(NotificationType.ACHIEVEMENT);
} }
// Test function for development // Test function for development
export function testAchievementSound() { export function testAchievementSound() {
try { try {
playAchievementSound(); playAchievementSound();
console.log('Achievement sound played successfully!'); console.log("Achievement sound played successfully!");
} catch (error) { } catch (error) {
console.error('Error playing achievement sound:', error); console.error("Error playing achievement sound:", error);
} }
} }
File diff suppressed because it is too large Load Diff
+104 -104
View File
@@ -1,130 +1,130 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from "svelte/store";
import { listen } from '@tauri-apps/api/event'; import { listen } from "@tauri-apps/api/event";
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
export interface UsageStats { export interface UsageStats {
total_input_tokens: number; total_input_tokens: number;
total_output_tokens: number; total_output_tokens: number;
total_cost_usd: number; total_cost_usd: number;
session_input_tokens: number; session_input_tokens: number;
session_output_tokens: number; session_output_tokens: number;
session_cost_usd: number; session_cost_usd: number;
model: string | null; model: string | null;
// New fields // New fields
messages_exchanged: number; messages_exchanged: number;
session_messages_exchanged: number; session_messages_exchanged: number;
code_blocks_generated: number; code_blocks_generated: number;
session_code_blocks_generated: number; session_code_blocks_generated: number;
files_edited: number; files_edited: number;
session_files_edited: number; session_files_edited: number;
files_created: number; files_created: number;
session_files_created: number; session_files_created: number;
tools_usage: Record<string, number>; tools_usage: Record<string, number>;
session_tools_usage: Record<string, number>; session_tools_usage: Record<string, number>;
session_duration_seconds: number; session_duration_seconds: number;
} }
// Main stats store // Main stats store
export const stats = writable<UsageStats>({ export const stats = writable<UsageStats>({
total_input_tokens: 0, total_input_tokens: 0,
total_output_tokens: 0, total_output_tokens: 0,
total_cost_usd: 0, total_cost_usd: 0,
session_input_tokens: 0, session_input_tokens: 0,
session_output_tokens: 0, session_output_tokens: 0,
session_cost_usd: 0, session_cost_usd: 0,
model: null, model: null,
messages_exchanged: 0, messages_exchanged: 0,
session_messages_exchanged: 0, session_messages_exchanged: 0,
code_blocks_generated: 0, code_blocks_generated: 0,
session_code_blocks_generated: 0, session_code_blocks_generated: 0,
files_edited: 0, files_edited: 0,
session_files_edited: 0, session_files_edited: 0,
files_created: 0, files_created: 0,
session_files_created: 0, session_files_created: 0,
tools_usage: {}, tools_usage: {},
session_tools_usage: {}, session_tools_usage: {},
session_duration_seconds: 0, session_duration_seconds: 0,
}); });
// Derived store for formatted display values // Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => { export const formattedStats = derived(stats, ($stats) => {
const formatNumber = (num: number) => num.toLocaleString(); const formatNumber = (num: number) => num.toLocaleString();
const formatCost = (cost: number) => `$${cost.toFixed(4)}`; const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60; const secs = seconds % 60;
if (hours > 0) { if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`; return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) { } else if (minutes > 0) {
return `${minutes}m ${secs}s`; return `${minutes}m ${secs}s`;
} else { } else {
return `${secs}s`; return `${secs}s`;
} }
}; };
return { return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens), totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens), totalInputTokens: formatNumber($stats.total_input_tokens),
totalOutputTokens: formatNumber($stats.total_output_tokens), totalOutputTokens: formatNumber($stats.total_output_tokens),
totalCost: formatCost($stats.total_cost_usd), totalCost: formatCost($stats.total_cost_usd),
sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens), sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens),
sessionInputTokens: formatNumber($stats.session_input_tokens), sessionInputTokens: formatNumber($stats.session_input_tokens),
sessionOutputTokens: formatNumber($stats.session_output_tokens), sessionOutputTokens: formatNumber($stats.session_output_tokens),
sessionCost: formatCost($stats.session_cost_usd), sessionCost: formatCost($stats.session_cost_usd),
model: $stats.model || 'No model selected', model: $stats.model || "No model selected",
// New formatted fields // New formatted fields
messagesTotal: formatNumber($stats.messages_exchanged), messagesTotal: formatNumber($stats.messages_exchanged),
messagesSession: formatNumber($stats.session_messages_exchanged), messagesSession: formatNumber($stats.session_messages_exchanged),
codeBlocksTotal: formatNumber($stats.code_blocks_generated), codeBlocksTotal: formatNumber($stats.code_blocks_generated),
codeBlocksSession: formatNumber($stats.session_code_blocks_generated), codeBlocksSession: formatNumber($stats.session_code_blocks_generated),
filesEditedTotal: formatNumber($stats.files_edited), filesEditedTotal: formatNumber($stats.files_edited),
filesEditedSession: formatNumber($stats.session_files_edited), filesEditedSession: formatNumber($stats.session_files_edited),
filesCreatedTotal: formatNumber($stats.files_created), filesCreatedTotal: formatNumber($stats.files_created),
filesCreatedSession: formatNumber($stats.session_files_created), filesCreatedSession: formatNumber($stats.session_files_created),
sessionDuration: formatDuration($stats.session_duration_seconds), sessionDuration: formatDuration($stats.session_duration_seconds),
toolsUsage: $stats.tools_usage, toolsUsage: $stats.tools_usage,
sessionToolsUsage: $stats.session_tools_usage, sessionToolsUsage: $stats.session_tools_usage,
}; };
}); });
// Note: Cost calculation is now done in the Rust backend // Note: Cost calculation is now done in the Rust backend
// Initialize stats listener // Initialize stats listener
export async function initStatsListener() { export async function initStatsListener() {
// Listen for stats updates from the backend // Listen for stats updates from the backend
await listen('claude:stats', (event) => { await listen("claude:stats", (event) => {
const payload = event.payload as { stats: UsageStats }; const payload = event.payload as { stats: UsageStats };
const { stats: newStats } = payload; const { stats: newStats } = payload;
// The backend already tracks all totals - just set the stats directly // The backend already tracks all totals - just set the stats directly
stats.set(newStats); stats.set(newStats);
}); });
// Load initial stats from backend // Load initial stats from backend
try { try {
const initialStats = await invoke<UsageStats>('get_usage_stats'); const initialStats = await invoke<UsageStats>("get_usage_stats");
stats.set(initialStats); stats.set(initialStats);
} catch (error) { } catch (error) {
console.error('Failed to load initial stats:', error); console.error("Failed to load initial stats:", error);
} }
} }
// Reset session stats (call when starting new session) // Reset session stats (call when starting new session)
export function resetSessionStats() { export function resetSessionStats() {
stats.update(current => ({ stats.update((current) => ({
...current, ...current,
session_input_tokens: 0, session_input_tokens: 0,
session_output_tokens: 0, session_output_tokens: 0,
session_cost_usd: 0, session_cost_usd: 0,
session_messages_exchanged: 0, session_messages_exchanged: 0,
session_code_blocks_generated: 0, session_code_blocks_generated: 0,
session_files_edited: 0, session_files_edited: 0,
session_files_created: 0, session_files_created: 0,
session_tools_usage: {}, session_tools_usage: {},
session_duration_seconds: 0, session_duration_seconds: 0,
})); }));
} }
+85 -85
View File
@@ -1,93 +1,93 @@
export interface AchievementUnlockedEvent { export interface AchievementUnlockedEvent {
achievement: { achievement: {
id: AchievementId;
name: string;
description: string;
icon: string;
unlocked_at: string | null;
};
}
export type AchievementId =
// Token Milestones
| 'FirstSteps' // 1,000 tokens
| 'GrowingStrong' // 10,000 tokens
| 'BlossomingCoder' // 100,000 tokens
| 'TokenMaster' // 1,000,000 tokens
// Code Generation
| 'HelloWorld' // First code block
| 'CodeWizard' // 100 code blocks
| 'ThousandBlocks' // 1,000 code blocks
// File Operations
| 'FileManipulator' // 10 files edited
| 'FileArchitect' // 100 files edited
// Conversation milestones
| 'ConversationStarter' // 10 messages
| 'ChattyKathy' // 100 messages
| 'Conversationalist' // 1,000 messages
// Tool usage
| 'Toolsmith' // 5 different tools
| 'ToolMaster' // 10 different tools
// Time-based achievements
| 'EarlyBird' // Started session 5-7 AM
| 'NightOwl' // Coding after midnight
| 'AllNighter' // Worked 2-5 AM
| 'WeekendWarrior' // Coding on weekend
| 'DedicatedDeveloper' // 30 days in a row
// Search and exploration
| 'Explorer' // 50 searches
| 'MasterSearcher' // 500 searches
// Session achievements
| 'QuickSession' // Productive session < 5 min
| 'FocusedWork' // 30 min session
| 'DeepDive' // 2 hour session
| 'MarathonSession' // 5+ hour session
// Special achievements
| 'FirstMessage' // First message sent
| 'FirstTool' // First tool used
| 'FirstCodeBlock' // First code generated
| 'FirstFileEdit' // First file edit
| 'Polyglot' // 5+ languages in one session
| 'SpeedCoder' // 10 code blocks in 10 minutes
| 'ClaudeConnoisseur' // Used all Claude models
| 'MarathonCoder' // 10k tokens in one session
// Relationship & Greetings
| 'GoodMorning' // Said good morning
| 'GoodNight' // Said good night
| 'ThankYou' // Said thank you
| 'LoveYou' // Said love you
// Personality & Fun
| 'EmojiUser' // Used 20+ emojis
| 'CapsLock' // ALL CAPS MESSAGE
| 'QuestionMaster' // Asked 50 questions
| 'PleaseAndThankYou' // Polite user
// Git & Development
| 'CommitMaster' // 100 commits
| 'PRO' // Created 10 PRs
| 'Reviewer' // Reviewed 10 PRs
| 'IssueTracker' // Created 25 issues
| 'GitGuru' // Used git commands
// Tool Mastery
| 'BashMaster' // Used bash 100 times
| 'FileExplorer' // Searched files 100 times
| 'SearchExpert' // Advanced searches
| 'AgentCommander' // Used task agents
| 'MCPMaster'; // Used MCP tools
export interface Achievement {
id: AchievementId; id: AchievementId;
name: string; name: string;
description: string; description: string;
icon: string; icon: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary'; unlocked_at: string | null;
unlocked: boolean; };
unlockedAt?: Date; }
progress?: number;
maxProgress?: number; export type AchievementId =
// Token Milestones
| "FirstSteps" // 1,000 tokens
| "GrowingStrong" // 10,000 tokens
| "BlossomingCoder" // 100,000 tokens
| "TokenMaster" // 1,000,000 tokens
// Code Generation
| "HelloWorld" // First code block
| "CodeWizard" // 100 code blocks
| "ThousandBlocks" // 1,000 code blocks
// File Operations
| "FileManipulator" // 10 files edited
| "FileArchitect" // 100 files edited
// Conversation milestones
| "ConversationStarter" // 10 messages
| "ChattyKathy" // 100 messages
| "Conversationalist" // 1,000 messages
// Tool usage
| "Toolsmith" // 5 different tools
| "ToolMaster" // 10 different tools
// Time-based achievements
| "EarlyBird" // Started session 5-7 AM
| "NightOwl" // Coding after midnight
| "AllNighter" // Worked 2-5 AM
| "WeekendWarrior" // Coding on weekend
| "DedicatedDeveloper" // 30 days in a row
// Search and exploration
| "Explorer" // 50 searches
| "MasterSearcher" // 500 searches
// Session achievements
| "QuickSession" // Productive session < 5 min
| "FocusedWork" // 30 min session
| "DeepDive" // 2 hour session
| "MarathonSession" // 5+ hour session
// Special achievements
| "FirstMessage" // First message sent
| "FirstTool" // First tool used
| "FirstCodeBlock" // First code generated
| "FirstFileEdit" // First file edit
| "Polyglot" // 5+ languages in one session
| "SpeedCoder" // 10 code blocks in 10 minutes
| "ClaudeConnoisseur" // Used all Claude models
| "MarathonCoder" // 10k tokens in one session
// Relationship & Greetings
| "GoodMorning" // Said good morning
| "GoodNight" // Said good night
| "ThankYou" // Said thank you
| "LoveYou" // Said love you
// Personality & Fun
| "EmojiUser" // Used 20+ emojis
| "CapsLock" // ALL CAPS MESSAGE
| "QuestionMaster" // Asked 50 questions
| "PleaseAndThankYou" // Polite user
// Git & Development
| "CommitMaster" // 100 commits
| "PRO" // Created 10 PRs
| "Reviewer" // Reviewed 10 PRs
| "IssueTracker" // Created 25 issues
| "GitGuru" // Used git commands
// Tool Mastery
| "BashMaster" // Used bash 100 times
| "FileExplorer" // Searched files 100 times
| "SearchExpert" // Advanced searches
| "AgentCommander" // Used task agents
| "MCPMaster"; // Used MCP tools
export interface Achievement {
id: AchievementId;
name: string;
description: string;
icon: string;
rarity: "common" | "rare" | "epic" | "legendary";
unlocked: boolean;
unlockedAt?: Date;
progress?: number;
maxProgress?: number;
} }
export interface AchievementCategory { export interface AchievementCategory {
name: string; name: string;
description: string; description: string;
achievements: Achievement[]; achievements: Achievement[];
} }
+5 -2
View File
@@ -36,7 +36,7 @@
</script> </script>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"> <div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar onToggleAchievements={() => achievementPanelOpen = !achievementPanelOpen} /> <StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
@@ -56,7 +56,10 @@
<PermissionModal /> <PermissionModal />
<ConfigSidebar /> <ConfigSidebar />
<AchievementNotification /> <AchievementNotification />
<AchievementsPanel bind:isOpen={achievementPanelOpen} onClose={() => achievementPanelOpen = false} /> <AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
</div> </div>
<style> <style>
+8 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { testAchievementSound } from '$lib/sounds/achievement'; import { testAchievementSound } from "$lib/sounds/achievement";
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
async function testSound() { async function testSound() {
testAchievementSound(); testAchievementSound();
@@ -9,12 +9,12 @@
async function triggerAchievement() { async function triggerAchievement() {
// This will trigger an achievement that hasn't been unlocked yet // This will trigger an achievement that hasn't been unlocked yet
try { try {
await invoke('check_achievements', { await invoke("check_achievements", {
eventType: 'message_sent', eventType: "message_sent",
data: {} data: {},
}); });
} catch (error) { } catch (error) {
console.error('Failed to trigger achievement:', error); console.error("Failed to trigger achievement:", error);
} }
} }
</script> </script>
@@ -38,8 +38,8 @@
</button> </button>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4"> <p class="text-sm text-gray-600 dark:text-gray-400 mt-4">
Click the first button to test just the sound effect.<br> Click the first button to test just the sound effect.<br />
Click the second button to trigger a real achievement (if any are available to unlock). Click the second button to trigger a real achievement (if any are available to unlock).
</p> </p>
</div> </div>
</div> </div>