generated from nhcarrigan/template
feat: stats and achievements (#45)
### Explanation _No response_ ### Issue Closes #39 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #45 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||
let showNotification = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievements.push(event.payload);
|
||||
if (!showNotification) {
|
||||
showNext();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function showNext() {
|
||||
if (achievements.length > 0) {
|
||||
currentAchievement = achievements.shift() || null;
|
||||
showNotification = true;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
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 {
|
||||
// Determine rarity based on achievement ID
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showNotification && currentAchievement}
|
||||
<div
|
||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Backdrop with animated gradient border -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||
<!-- Animated gradient border -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} animate-pulse"
|
||||
></div>
|
||||
|
||||
<!-- Main notification content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={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"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||
|
||||
<!-- Sparkle animations -->
|
||||
<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 class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<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"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{currentAchievement.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{currentAchievement.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<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"
|
||||
>
|
||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array(10) as _ (_)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||
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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import {
|
||||
achievementsStore,
|
||||
achievementProgress,
|
||||
achievementCategories,
|
||||
} from "$lib/stores/achievements";
|
||||
import type { Achievement } from "$lib/types/achievements";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const { isOpen = $bindable(false), onClose }: Props = $props();
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const achievementsState = $derived($achievementsStore);
|
||||
const progress = $derived($achievementProgress);
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "text-yellow-500 dark:text-yellow-400";
|
||||
case "epic":
|
||||
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 {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "bg-yellow-500/10";
|
||||
case "epic":
|
||||
return "bg-purple-500/10";
|
||||
case "rare":
|
||||
return "bg-blue-500/10";
|
||||
default:
|
||||
return "bg-green-500/10";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | undefined): string {
|
||||
if (!date) return "";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
|
||||
return categoryIds
|
||||
.map(
|
||||
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Achievements panel -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === "Escape" && onClose?.()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close achievements panel"
|
||||
transition:slide={{ duration: 300, easing: quintOut }}
|
||||
></div>
|
||||
|
||||
<div
|
||||
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"
|
||||
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === "Enter" && onClose?.()}
|
||||
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">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overall progress -->
|
||||
<div class="mt-4">
|
||||
<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.percentage}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
|
||||
style="width: {progress.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each achievementCategories as category (category.name)}
|
||||
{@const achievements = getAchievementsForCategory(category.ids)}
|
||||
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
|
||||
|
||||
<div class="border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() =>
|
||||
(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"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{unlockedCount} / {achievements.length}
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform {selectedCategory === category.name
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if selectedCategory === category.name}
|
||||
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
|
||||
{#each achievements as achievement (achievement.id)}
|
||||
<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'}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
|
||||
{achievement.icon}
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-[var(--text-primary)]">
|
||||
{achievement.name}
|
||||
</h4>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
|
||||
achievement.rarity
|
||||
)} {getRarityColor(achievement.rarity)} capitalize"
|
||||
>
|
||||
{achievement.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{achievement.description}
|
||||
</p>
|
||||
|
||||
{#if achievement.unlocked && achievement.unlockedAt}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
||||
Unlocked {formatDate(achievement.unlockedAt)}
|
||||
</p>
|
||||
{:else if achievement.maxProgress && achievement.progress !== undefined}
|
||||
<!-- Progress bar for locked achievements -->
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{achievement.progress} / {achievement.maxProgress}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style="width: {Math.min(
|
||||
(achievement.progress / achievement.maxProgress) * 100,
|
||||
100
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer with last unlocked -->
|
||||
{#if achievementsState.lastUnlocked}
|
||||
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
|
||||
<div>
|
||||
<p class="font-semibold text-[var(--text-primary)]">
|
||||
{achievementsState.lastUnlocked.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar for achievement list */
|
||||
:global(.overflow-y-auto::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-track) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@
|
||||
disabled={!isConnected || isSubmitting}
|
||||
rows={1}
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||
rounded-lg text-white placeholder-gray-500 resize-none
|
||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { formattedStats } from "$lib/stores/stats";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
let showToolsBreakdown = false;
|
||||
</script>
|
||||
|
||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||
<div class="stats-row">
|
||||
<span class="stat-label">Duration:</span>
|
||||
<span class="stat-value">{$formattedStats.sessionDuration}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<span class="stat-label">Messages:</span>
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
|
||||
</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">
|
||||
<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] (tool)}
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.stats-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stats-section h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.25rem 0;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin-left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-highlight {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.model-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,10 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onToggleAchievements?: () => void;
|
||||
}
|
||||
|
||||
const { onToggleAchievements = () => {} }: Props = $props();
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
@@ -7,6 +13,8 @@
|
||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
|
||||
@@ -16,6 +24,8 @@
|
||||
let isConnecting = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let appVersion = $state("");
|
||||
let showStats = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -126,6 +136,10 @@
|
||||
return "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAchievements() {
|
||||
onToggleAchievements();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -167,6 +181,36 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={toggleAchievements}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
|
||||
title="Achievements"
|
||||
>
|
||||
<span class="text-lg">🏆</span>
|
||||
{#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]"
|
||||
>
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: ''}"
|
||||
title="Usage Stats"
|
||||
>
|
||||
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={configStore.openSidebar}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
@@ -201,6 +245,12 @@
|
||||
{#if appVersion}
|
||||
<span class="text-xs text-gray-600">v{appVersion}</span>
|
||||
{/if}
|
||||
|
||||
{#if showStats}
|
||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionStatus === "connected"}
|
||||
<button
|
||||
onclick={handleDisconnect}
|
||||
@@ -219,3 +269,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStats}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||
<div class="fixed top-14 right-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -25,17 +25,17 @@
|
||||
function getLineClass(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "text-cyan-400";
|
||||
return "terminal-user";
|
||||
case "assistant":
|
||||
return "text-gray-100";
|
||||
return "terminal-assistant";
|
||||
case "system":
|
||||
return "text-gray-500 italic";
|
||||
return "terminal-system italic";
|
||||
case "tool":
|
||||
return "text-purple-400";
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
return "terminal-error";
|
||||
default:
|
||||
return "text-gray-300";
|
||||
return "terminal-default";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 ml-2">Terminal</span>
|
||||
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -84,16 +84,18 @@
|
||||
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="text-gray-500 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}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)}">
|
||||
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span>
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="text-purple-300 mr-2">[{line.toolName}]</span>
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
<span class="whitespace-pre-wrap">{line.content}</span>
|
||||
</div>
|
||||
@@ -107,4 +109,49 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) var(--bg-terminal);
|
||||
}
|
||||
|
||||
/* Terminal text colors that adapt to theme */
|
||||
.terminal-user {
|
||||
color: var(--terminal-user, #22d3ee);
|
||||
}
|
||||
|
||||
.terminal-assistant {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-system {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-tool {
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-default {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.terminal-timestamp {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
}
|
||||
|
||||
.terminal-prefix {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-tool-name {
|
||||
color: var(--terminal-tool-name, #ddd6fe);
|
||||
}
|
||||
|
||||
.terminal-waiting {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum NotificationType {
|
||||
PERMISSION = "permission",
|
||||
CONNECTION = "connection",
|
||||
TASK_START = "task_start",
|
||||
ACHIEVEMENT = "achievement",
|
||||
}
|
||||
|
||||
export interface NotificationSound {
|
||||
@@ -45,4 +46,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
|
||||
phrase: "Working on it!",
|
||||
volume: 0.6,
|
||||
},
|
||||
[NotificationType.ACHIEVEMENT]: {
|
||||
type: NotificationType.ACHIEVEMENT,
|
||||
filename: "achievement.mp3",
|
||||
phrase: "Achievement Get~!",
|
||||
volume: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Achievement sound player using the notification system
|
||||
|
||||
import { soundPlayer } from "$lib/notifications";
|
||||
import { NotificationType } from "$lib/notifications/types";
|
||||
|
||||
export function playAchievementSound() {
|
||||
// Use the soundPlayer which respects global notification settings
|
||||
soundPlayer.play(NotificationType.ACHIEVEMENT);
|
||||
}
|
||||
|
||||
// Test function for development
|
||||
export function testAchievementSound() {
|
||||
try {
|
||||
playAchievementSound();
|
||||
console.log("Achievement sound played successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error playing achievement sound:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Achievement, AchievementUnlockedEvent, AchievementId } from "$lib/types/achievements";
|
||||
import { playAchievementSound } from "$lib/sounds/achievement";
|
||||
|
||||
interface AchievementState {
|
||||
achievements: Record<AchievementId, Achievement>;
|
||||
totalUnlocked: number;
|
||||
lastUnlocked: Achievement | null;
|
||||
}
|
||||
|
||||
// Initial achievement definitions
|
||||
const achievementDefinitions: Record<
|
||||
AchievementId,
|
||||
Omit<Achievement, "unlocked" | "unlockedAt">
|
||||
> = {
|
||||
// Token milestones
|
||||
FirstSteps: {
|
||||
id: "FirstSteps",
|
||||
name: "First Steps",
|
||||
description: "Generated your first 1,000 tokens",
|
||||
icon: "👶",
|
||||
rarity: "common",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
GrowingStrong: {
|
||||
id: "GrowingStrong",
|
||||
name: "Growing Strong",
|
||||
description: "Reached 10,000 tokens total",
|
||||
icon: "🌱",
|
||||
rarity: "common",
|
||||
maxProgress: 10000,
|
||||
},
|
||||
BlossomingCoder: {
|
||||
id: "BlossomingCoder",
|
||||
name: "Blossoming Coder",
|
||||
description: "Generated 100,000 tokens - you're really growing!",
|
||||
icon: "🌸",
|
||||
rarity: "rare",
|
||||
maxProgress: 100000,
|
||||
},
|
||||
TokenMaster: {
|
||||
id: "TokenMaster",
|
||||
name: "Token Master",
|
||||
description: "One million tokens! You're unstoppable!",
|
||||
icon: "👑",
|
||||
rarity: "legendary",
|
||||
maxProgress: 1000000,
|
||||
},
|
||||
|
||||
// Code generation
|
||||
HelloWorld: {
|
||||
id: "HelloWorld",
|
||||
name: "Hello, World!",
|
||||
description: "Generated your first code block",
|
||||
icon: "👋",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
CodeWizard: {
|
||||
id: "CodeWizard",
|
||||
name: "Code Wizard",
|
||||
description: "100 code blocks generated",
|
||||
icon: "🧙♀️",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
ThousandBlocks: {
|
||||
id: "ThousandBlocks",
|
||||
name: "Thousand Blocks",
|
||||
description: "1,000 code blocks! You're a code machine!",
|
||||
icon: "🏗️",
|
||||
rarity: "epic",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// File operations
|
||||
FileManipulator: {
|
||||
id: "FileManipulator",
|
||||
name: "File Manipulator",
|
||||
description: "Edited 10 files",
|
||||
icon: "📝",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
FileArchitect: {
|
||||
id: "FileArchitect",
|
||||
name: "File Architect",
|
||||
description: "Created or edited 100 files",
|
||||
icon: "🏛️",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
|
||||
// Conversation milestones
|
||||
ConversationStarter: {
|
||||
id: "ConversationStarter",
|
||||
name: "Conversation Starter",
|
||||
description: "Exchanged 10 messages",
|
||||
icon: "💬",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
ChattyKathy: {
|
||||
id: "ChattyKathy",
|
||||
name: "Chatty Kathy",
|
||||
description: "100 messages exchanged",
|
||||
icon: "🗣️",
|
||||
rarity: "common",
|
||||
maxProgress: 100,
|
||||
},
|
||||
Conversationalist: {
|
||||
id: "Conversationalist",
|
||||
name: "Master Conversationalist",
|
||||
description: "1,000 messages! We're really connecting!",
|
||||
icon: "💖",
|
||||
rarity: "rare",
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// Tool usage
|
||||
Toolsmith: {
|
||||
id: "Toolsmith",
|
||||
name: "Toolsmith",
|
||||
description: "Used 5 different tools",
|
||||
icon: "🔨",
|
||||
rarity: "common",
|
||||
maxProgress: 5,
|
||||
},
|
||||
ToolMaster: {
|
||||
id: "ToolMaster",
|
||||
name: "Tool Master",
|
||||
description: "Used 10 different tools efficiently",
|
||||
icon: "🛠️",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
|
||||
// Time-based achievements
|
||||
EarlyBird: {
|
||||
id: "EarlyBird",
|
||||
name: "Early Bird",
|
||||
description: "Started a session between 5 AM and 7 AM",
|
||||
icon: "🌅",
|
||||
rarity: "common",
|
||||
},
|
||||
NightOwl: {
|
||||
id: "NightOwl",
|
||||
name: "Night Owl",
|
||||
description: "Coding after midnight",
|
||||
icon: "🦉",
|
||||
rarity: "common",
|
||||
},
|
||||
AllNighter: {
|
||||
id: "AllNighter",
|
||||
name: "All Nighter",
|
||||
description: "Worked through the night (2 AM - 5 AM)",
|
||||
icon: "🌙",
|
||||
rarity: "rare",
|
||||
},
|
||||
WeekendWarrior: {
|
||||
id: "WeekendWarrior",
|
||||
name: "Weekend Warrior",
|
||||
description: "Coding on a weekend",
|
||||
icon: "⚔️",
|
||||
rarity: "common",
|
||||
},
|
||||
DedicatedDeveloper: {
|
||||
id: "DedicatedDeveloper",
|
||||
name: "Dedicated Developer",
|
||||
description: "Coded for 30 days in a row",
|
||||
icon: "🏆",
|
||||
rarity: "legendary",
|
||||
},
|
||||
|
||||
// Search and exploration
|
||||
Explorer: {
|
||||
id: "Explorer",
|
||||
name: "Explorer",
|
||||
description: "Used search tools 50 times",
|
||||
icon: "🔍",
|
||||
rarity: "common",
|
||||
maxProgress: 50,
|
||||
},
|
||||
MasterSearcher: {
|
||||
id: "MasterSearcher",
|
||||
name: "Master Searcher",
|
||||
description: "Searched 500 times across files",
|
||||
icon: "🕵️♀️",
|
||||
rarity: "rare",
|
||||
maxProgress: 500,
|
||||
},
|
||||
|
||||
// Session achievements
|
||||
QuickSession: {
|
||||
id: "QuickSession",
|
||||
name: "Quick Session",
|
||||
description: "Completed a productive session in under 5 minutes",
|
||||
icon: "⚡",
|
||||
rarity: "common",
|
||||
},
|
||||
FocusedWork: {
|
||||
id: "FocusedWork",
|
||||
name: "Focused Work",
|
||||
description: "Worked for 30 minutes straight",
|
||||
icon: "🎯",
|
||||
rarity: "common",
|
||||
},
|
||||
DeepDive: {
|
||||
id: "DeepDive",
|
||||
name: "Deep Dive",
|
||||
description: "Worked for 2 hours continuously",
|
||||
icon: "🏊♀️",
|
||||
rarity: "rare",
|
||||
},
|
||||
MarathonSession: {
|
||||
id: "MarathonSession",
|
||||
name: "Marathon Session",
|
||||
description: "5+ hour coding session!",
|
||||
icon: "🏃♀️",
|
||||
rarity: "epic",
|
||||
},
|
||||
|
||||
// Special achievements
|
||||
FirstMessage: {
|
||||
id: "FirstMessage",
|
||||
name: "First Message",
|
||||
description: "Sent your first message to Hikari",
|
||||
icon: "✨",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstTool: {
|
||||
id: "FirstTool",
|
||||
name: "First Tool",
|
||||
description: "Used your first tool",
|
||||
icon: "🔧",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstCodeBlock: {
|
||||
id: "FirstCodeBlock",
|
||||
name: "First Code",
|
||||
description: "Generated your first code block",
|
||||
icon: "📦",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstFileEdit: {
|
||||
id: "FirstFileEdit",
|
||||
name: "First Edit",
|
||||
description: "Made your first file edit",
|
||||
icon: "✏️",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
Polyglot: {
|
||||
id: "Polyglot",
|
||||
name: "Polyglot",
|
||||
description: "Generated code in 5+ languages in one session",
|
||||
icon: "🌍",
|
||||
rarity: "rare",
|
||||
maxProgress: 5,
|
||||
},
|
||||
SpeedCoder: {
|
||||
id: "SpeedCoder",
|
||||
name: "Speed Coder",
|
||||
description: "Generated 10 code blocks in 10 minutes",
|
||||
icon: "🚀",
|
||||
rarity: "rare",
|
||||
},
|
||||
ClaudeConnoisseur: {
|
||||
id: "ClaudeConnoisseur",
|
||||
name: "Claude Connoisseur",
|
||||
description: "Used all available Claude models",
|
||||
icon: "🎨",
|
||||
rarity: "epic",
|
||||
maxProgress: 5, // Adjust based on available models
|
||||
},
|
||||
MarathonCoder: {
|
||||
id: "MarathonCoder",
|
||||
name: "Marathon Coder",
|
||||
description: "10,000 tokens in a single session",
|
||||
icon: "🏃♂️",
|
||||
rarity: "epic",
|
||||
maxProgress: 10000,
|
||||
},
|
||||
|
||||
// Relationship & Greetings
|
||||
GoodMorning: {
|
||||
id: "GoodMorning",
|
||||
name: "Good Morning!",
|
||||
description: "Greeted Hikari with a good morning",
|
||||
icon: "🌅",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
GoodNight: {
|
||||
id: "GoodNight",
|
||||
name: "Sweet Dreams",
|
||||
description: "Said good night to Hikari",
|
||||
icon: "🌙",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
ThankYou: {
|
||||
id: "ThankYou",
|
||||
name: "Grateful Heart",
|
||||
description: "Thanked Hikari for her help",
|
||||
icon: "🙏",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
LoveYou: {
|
||||
id: "LoveYou",
|
||||
name: "Heartfelt",
|
||||
description: "Expressed love to Hikari",
|
||||
icon: "💕",
|
||||
rarity: "rare",
|
||||
maxProgress: 1,
|
||||
},
|
||||
|
||||
// Personality & Fun
|
||||
EmojiUser: {
|
||||
id: "EmojiUser",
|
||||
name: "Emoji Master",
|
||||
description: "Used 20+ emojis in messages",
|
||||
icon: "😄",
|
||||
rarity: "common",
|
||||
maxProgress: 20,
|
||||
},
|
||||
CapsLock: {
|
||||
id: "CapsLock",
|
||||
name: "CAPS LOCK",
|
||||
description: "SENT A MESSAGE IN ALL CAPS",
|
||||
icon: "🔊",
|
||||
rarity: "common",
|
||||
maxProgress: 1,
|
||||
},
|
||||
QuestionMaster: {
|
||||
id: "QuestionMaster",
|
||||
name: "Question Master",
|
||||
description: "Asked 50 questions",
|
||||
icon: "❓",
|
||||
rarity: "common",
|
||||
maxProgress: 50,
|
||||
},
|
||||
PleaseAndThankYou: {
|
||||
id: "PleaseAndThankYou",
|
||||
name: "Polite Programmer",
|
||||
description: "Always says please and thank you",
|
||||
icon: "🎩",
|
||||
rarity: "common",
|
||||
maxProgress: 10,
|
||||
},
|
||||
|
||||
// Git & Development
|
||||
CommitMaster: {
|
||||
id: "CommitMaster",
|
||||
name: "Commit Master",
|
||||
description: "Made 100 commits through Hikari",
|
||||
icon: "📝",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
PRO: {
|
||||
id: "PRO",
|
||||
name: "PRO",
|
||||
description: "Created 10 pull requests",
|
||||
icon: "🔀",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
Reviewer: {
|
||||
id: "Reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "Reviewed 10 pull requests",
|
||||
icon: "👀",
|
||||
rarity: "rare",
|
||||
maxProgress: 10,
|
||||
},
|
||||
IssueTracker: {
|
||||
id: "IssueTracker",
|
||||
name: "Issue Tracker",
|
||||
description: "Created 25 issues",
|
||||
icon: "🎯",
|
||||
rarity: "rare",
|
||||
maxProgress: 25,
|
||||
},
|
||||
GitGuru: {
|
||||
id: "GitGuru",
|
||||
name: "Git Guru",
|
||||
description: "Mastered git operations",
|
||||
icon: "🌲",
|
||||
rarity: "epic",
|
||||
},
|
||||
|
||||
// Tool Mastery
|
||||
BashMaster: {
|
||||
id: "BashMaster",
|
||||
name: "Bash Master",
|
||||
description: "Used bash commands 100 times",
|
||||
icon: "💻",
|
||||
rarity: "rare",
|
||||
maxProgress: 100,
|
||||
},
|
||||
FileExplorer: {
|
||||
id: "FileExplorer",
|
||||
name: "File Explorer",
|
||||
description: "Explored files 100 times",
|
||||
icon: "📂",
|
||||
rarity: "common",
|
||||
maxProgress: 100,
|
||||
},
|
||||
SearchExpert: {
|
||||
id: "SearchExpert",
|
||||
name: "Search Expert",
|
||||
description: "Mastered advanced search queries",
|
||||
icon: "🔎",
|
||||
rarity: "rare",
|
||||
},
|
||||
AgentCommander: {
|
||||
id: "AgentCommander",
|
||||
name: "Agent Commander",
|
||||
description: "Used task agents effectively",
|
||||
icon: "🤖",
|
||||
rarity: "rare",
|
||||
},
|
||||
MCPMaster: {
|
||||
id: "MCPMaster",
|
||||
name: "MCP Master",
|
||||
description: "Mastered MCP tool usage",
|
||||
icon: "🛠️",
|
||||
rarity: "epic",
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize all achievements as locked
|
||||
const initialAchievements: Record<AchievementId, Achievement> = {} as Record<
|
||||
AchievementId,
|
||||
Achievement
|
||||
>;
|
||||
for (const [id, def] of Object.entries(achievementDefinitions)) {
|
||||
initialAchievements[id as AchievementId] = {
|
||||
...def,
|
||||
unlocked: false,
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Create the main store
|
||||
function createAchievementsStore() {
|
||||
const { subscribe, update } = writable<AchievementState>({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
unlockAchievement: (event: AchievementUnlockedEvent, playSound: boolean = true) => {
|
||||
update((state) => {
|
||||
const achievement = state.achievements[event.achievement.id];
|
||||
if (achievement && !achievement.unlocked) {
|
||||
achievement.unlocked = true;
|
||||
achievement.unlockedAt = event.achievement.unlocked_at
|
||||
? new Date(event.achievement.unlocked_at)
|
||||
: new Date();
|
||||
state.totalUnlocked++;
|
||||
state.lastUnlocked = achievement;
|
||||
|
||||
// Play achievement sound only for new unlocks, not when loading saved ones
|
||||
if (playSound) {
|
||||
try {
|
||||
playAchievementSound();
|
||||
} catch (error) {
|
||||
console.error("Failed to play achievement sound:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
updateProgress: (id: AchievementId, progress: number) => {
|
||||
update((state) => {
|
||||
const achievement = state.achievements[id];
|
||||
if (achievement) {
|
||||
achievement.progress = progress;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
update(() => ({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const achievementsStore = createAchievementsStore();
|
||||
|
||||
// Derived stores for different views
|
||||
export const unlockedAchievements = derived(achievementsStore, ($store) =>
|
||||
Object.values($store.achievements).filter((a) => a.unlocked)
|
||||
);
|
||||
|
||||
export const lockedAchievements = derived(achievementsStore, ($store) =>
|
||||
Object.values($store.achievements).filter((a) => !a.unlocked)
|
||||
);
|
||||
|
||||
export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
||||
const byRarity: Record<string, Achievement[]> = {
|
||||
common: [],
|
||||
rare: [],
|
||||
epic: [],
|
||||
legendary: [],
|
||||
};
|
||||
|
||||
for (const achievement of Object.values($store.achievements)) {
|
||||
byRarity[achievement.rarity].push(achievement);
|
||||
}
|
||||
|
||||
return byRarity;
|
||||
});
|
||||
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
}));
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
// Listen for achievement unlocked events
|
||||
await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievementsStore.unlockAchievement(event.payload);
|
||||
});
|
||||
|
||||
// Load saved achievements from persistent storage
|
||||
try {
|
||||
const savedAchievements = await invoke<AchievementUnlockedEvent[]>("load_saved_achievements");
|
||||
|
||||
// Update the store with saved achievements (don't play sounds)
|
||||
for (const event of savedAchievements) {
|
||||
achievementsStore.unlockAchievement(event, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load saved achievements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export achievement categories for the display panel
|
||||
export const achievementCategories = [
|
||||
{
|
||||
name: "Token Milestones",
|
||||
description: "Track your token generation progress",
|
||||
ids: ["FirstSteps", "GrowingStrong", "BlossomingCoder", "TokenMaster"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Code Generation",
|
||||
description: "Achievements for generating code",
|
||||
ids: ["HelloWorld", "CodeWizard", "ThousandBlocks"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "File Operations",
|
||||
description: "Working with files and projects",
|
||||
ids: ["FileManipulator", "FileArchitect"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Conversations",
|
||||
description: "Building our relationship through chat",
|
||||
ids: ["ConversationStarter", "ChattyKathy", "Conversationalist"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Tools & Skills",
|
||||
description: "Mastering different tools",
|
||||
ids: ["Toolsmith", "ToolMaster"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Time-Based",
|
||||
description: "When you code matters too!",
|
||||
ids: [
|
||||
"EarlyBird",
|
||||
"NightOwl",
|
||||
"AllNighter",
|
||||
"WeekendWarrior",
|
||||
"DedicatedDeveloper",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Search & Explore",
|
||||
description: "Finding what you need",
|
||||
ids: ["Explorer", "MasterSearcher"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Session Records",
|
||||
description: "Your coding session achievements",
|
||||
ids: [
|
||||
"QuickSession",
|
||||
"FocusedWork",
|
||||
"DeepDive",
|
||||
"MarathonSession",
|
||||
"MarathonCoder",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Relationship & Greetings",
|
||||
description: "Our special moments together",
|
||||
ids: ["GoodMorning", "GoodNight", "ThankYou", "LoveYou"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Personality & Fun",
|
||||
description: "Express yourself!",
|
||||
ids: ["EmojiUser", "CapsLock", "QuestionMaster", "PleaseAndThankYou"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Git & Development",
|
||||
description: "Version control mastery",
|
||||
ids: ["CommitMaster", "PRO", "Reviewer", "IssueTracker", "GitGuru"] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Tool Mastery",
|
||||
description: "Master of all tools",
|
||||
ids: [
|
||||
"BashMaster",
|
||||
"FileExplorer",
|
||||
"SearchExpert",
|
||||
"AgentCommander",
|
||||
"MCPMaster",
|
||||
] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: "Special",
|
||||
description: "Unique accomplishments",
|
||||
ids: [
|
||||
"FirstMessage",
|
||||
"FirstTool",
|
||||
"FirstCodeBlock",
|
||||
"FirstFileEdit",
|
||||
"Polyglot",
|
||||
"SpeedCoder",
|
||||
"ClaudeConnoisseur",
|
||||
] as AchievementId[],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,130 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface UsageStats {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_usd: number;
|
||||
session_input_tokens: number;
|
||||
session_output_tokens: number;
|
||||
session_cost_usd: number;
|
||||
model: string | null;
|
||||
|
||||
// New fields
|
||||
messages_exchanged: number;
|
||||
session_messages_exchanged: number;
|
||||
code_blocks_generated: number;
|
||||
session_code_blocks_generated: number;
|
||||
files_edited: number;
|
||||
session_files_edited: number;
|
||||
files_created: number;
|
||||
session_files_created: number;
|
||||
tools_usage: Record<string, number>;
|
||||
session_tools_usage: Record<string, number>;
|
||||
session_duration_seconds: number;
|
||||
}
|
||||
|
||||
// Main stats store
|
||||
export const stats = writable<UsageStats>({
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cost_usd: 0,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
model: null,
|
||||
messages_exchanged: 0,
|
||||
session_messages_exchanged: 0,
|
||||
code_blocks_generated: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
files_edited: 0,
|
||||
session_files_edited: 0,
|
||||
files_created: 0,
|
||||
session_files_created: 0,
|
||||
tools_usage: {},
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
});
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
totalOutputTokens: formatNumber($stats.total_output_tokens),
|
||||
totalCost: formatCost($stats.total_cost_usd),
|
||||
sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens),
|
||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||
sessionCost: formatCost($stats.session_cost_usd),
|
||||
model: $stats.model || "No model selected",
|
||||
|
||||
// New formatted fields
|
||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||
messagesSession: formatNumber($stats.session_messages_exchanged),
|
||||
codeBlocksTotal: formatNumber($stats.code_blocks_generated),
|
||||
codeBlocksSession: formatNumber($stats.session_code_blocks_generated),
|
||||
filesEditedTotal: formatNumber($stats.files_edited),
|
||||
filesEditedSession: formatNumber($stats.session_files_edited),
|
||||
filesCreatedTotal: formatNumber($stats.files_created),
|
||||
filesCreatedSession: formatNumber($stats.session_files_created),
|
||||
sessionDuration: formatDuration($stats.session_duration_seconds),
|
||||
toolsUsage: $stats.tools_usage,
|
||||
sessionToolsUsage: $stats.session_tools_usage,
|
||||
};
|
||||
});
|
||||
|
||||
// Note: Cost calculation is now done in the Rust backend
|
||||
|
||||
// Initialize stats listener
|
||||
export async function initStatsListener() {
|
||||
// Listen for stats updates from the backend
|
||||
await listen("claude:stats", (event) => {
|
||||
const payload = event.payload as { stats: UsageStats };
|
||||
const { stats: newStats } = payload;
|
||||
|
||||
// The backend already tracks all totals - just set the stats directly
|
||||
stats.set(newStats);
|
||||
});
|
||||
|
||||
// Load initial stats from backend
|
||||
try {
|
||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
||||
stats.set(initialStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset session stats (call when starting new session)
|
||||
export function resetSessionStats() {
|
||||
stats.update((current) => ({
|
||||
...current,
|
||||
session_input_tokens: 0,
|
||||
session_output_tokens: 0,
|
||||
session_cost_usd: 0,
|
||||
session_messages_exchanged: 0,
|
||||
session_code_blocks_generated: 0,
|
||||
session_files_edited: 0,
|
||||
session_files_created: 0,
|
||||
session_tools_usage: {},
|
||||
session_duration_seconds: 0,
|
||||
}));
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
||||
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import {
|
||||
@@ -76,6 +78,12 @@ export async function initializeTauriListeners() {
|
||||
// Initialize notification rules
|
||||
initializeNotificationRules();
|
||||
|
||||
// Initialize stats listener
|
||||
await initStatsListener();
|
||||
|
||||
// Initialize achievements listener
|
||||
await initAchievementsListener();
|
||||
|
||||
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
||||
const status = event.payload as ConnectionStatus;
|
||||
claudeStore.setConnectionStatus(status);
|
||||
@@ -88,6 +96,7 @@ export async function initializeTauriListeners() {
|
||||
characterState.setState("idle");
|
||||
if (!hasConnectedThisSession) {
|
||||
hasConnectedThisSession = true;
|
||||
resetSessionStats(); // Reset session stats on new connection
|
||||
await sendGreeting();
|
||||
}
|
||||
} else if (status === "disconnected") {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
export interface AchievementUnlockedEvent {
|
||||
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;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
rarity: "common" | "rare" | "epic" | "legendary";
|
||||
unlocked: boolean;
|
||||
unlockedAt?: Date;
|
||||
progress?: number;
|
||||
maxProgress?: number;
|
||||
}
|
||||
|
||||
export interface AchievementCategory {
|
||||
name: string;
|
||||
description: string;
|
||||
achievements: Achievement[];
|
||||
}
|
||||
Reference in New Issue
Block a user