diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..4c728c2 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to Kal will be documented in this file. + +--- + +## [1.0.2] - 2026-04-07 + +### Added + +- Per-minute usage progress bar in the Rate Limits documentation tab — see your real-time minute-level usage alongside daily limits +- Numbered page navigation on the Food Database — quickly jump to any page instead of clicking through one at a time +- Live reset countdown timers on dashboard usage cards — see exactly when your daily, monthly, and per-minute limits reset + +### Fixed + +- Corrected example Food IDs in the Setup page API quick reference to match actual records + +--- + +## [1.0.1] - 2026-04-06 + +### Fixed + +- Rate limit usage stats now only count successful requests — previously, rate-limited (429) responses were still counted against your usage, which made your dashboard stats appear higher than actual usage +- Improved error tracking in request logs with clearer error messages for failed requests + +--- + +## [1.0.0] - 2026-03-17 + +### Added + +- API v1 with versioned endpoints (`/api/v1/*`) +- Food database with comprehensive nutritional data +- API key management dashboard +- Request logging and analytics +- Setup wizard with code examples +- Feedback and bug reporting system +- Collapsible sidebar with mobile-responsive drawer + +### Changed + +- Migrated all API endpoints to versioned paths +- Improved dashboard performance and loading states + +--- diff --git a/packages/kal-backend/src/routers/api-keys.ts b/packages/kal-backend/src/routers/api-keys.ts index fca0a58..56c33c0 100644 --- a/packages/kal-backend/src/routers/api-keys.ts +++ b/packages/kal-backend/src/routers/api-keys.ts @@ -200,11 +200,63 @@ export const apiKeysRouter = router({ isRevoked: false, }); + // Minute usage — only valid if minuteWindow is within the current minute + const now = new Date(); + const currentMinuteStart = new Date(now); + currentMinuteStart.setSeconds(0, 0); + + const minuteWindowIsStale = + !usage?.minuteWindow || + new Date(usage.minuteWindow).getTime() < currentMinuteStart.getTime(); + + const minuteUsed = minuteWindowIsStale ? 0 : usage?.minuteCount || 0; + return { tier: user?.tier || "free", dailyUsed: usage?.dailyCount || 0, monthlyUsed, activeKeyCount, + minuteUsed, + }; + }), + + /** + * Get key-specific stats: active, revoked, and expired counts + */ + getKeyStats: protectedProcedure.query(async ({ ctx }) => { + const now = new Date(); + const keysCollection = ctx.db.collection("api_keys"); + + const [activeCount, revokedCount, expiredCount, totalCount] = + await Promise.all([ + // Active: not revoked and not expired + keysCollection.countDocuments({ + userId: ctx.userId, + isRevoked: false, + $or: [{ expiresAt: null }, { expiresAt: { $gt: now } }], + }), + // Revoked + keysCollection.countDocuments({ + userId: ctx.userId, + isRevoked: true, + }), + // Expired: not revoked but past expiration + keysCollection.countDocuments({ + userId: ctx.userId, + isRevoked: false, + expiresAt: { $ne: null, $lte: now }, + }), + // Total ever created + keysCollection.countDocuments({ + userId: ctx.userId, + }), + ]); + + return { + active: activeCount, + revoked: revokedCount, + expired: expiredCount, + total: totalCount, }; }), diff --git a/packages/kal-frontend/src/app/dashboard/api-keys/client.tsx b/packages/kal-frontend/src/app/dashboard/api-keys/client.tsx index 53843ee..e7b1290 100644 --- a/packages/kal-frontend/src/app/dashboard/api-keys/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/api-keys/client.tsx @@ -1,9 +1,16 @@ "use client"; import type { ApiKeyExpiration } from "kal-shared"; -import { RATE_LIMITS } from "kal-shared"; import { useState } from "react"; -import { AlertTriangle, Check, CheckCircle, Plus } from "react-feather"; +import { + AlertTriangle, + Check, + CheckCircle, + Key, + Plus, + Slash, + Clock, +} from "react-feather"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { AuthUpdater, useAuth } from "@/lib/auth-context"; @@ -26,7 +33,11 @@ interface ApiKeysClientProps { name?: string | null; } -export default function ApiKeysClient({ logtoId, email, name }: ApiKeysClientProps) { +export default function ApiKeysClient({ + logtoId, + email, + name, +}: ApiKeysClientProps) { return ( <> @@ -35,9 +46,13 @@ export default function ApiKeysClient({ logtoId, email, name }: ApiKeysClientPro ); } -function ApiKeysContentWrapper({ expectedLogtoId }: { expectedLogtoId?: string }) { +function ApiKeysContentWrapper({ + expectedLogtoId, +}: { + expectedLogtoId?: string; +}) { const { logtoId } = useAuth(); - + if (expectedLogtoId && logtoId !== expectedLogtoId) { return (
@@ -56,12 +71,14 @@ function ApiKeysContent() { const { isMobile } = useBreakpoint(); const [showGenerateModal, setShowGenerateModal] = useState(false); const [newKeyName, setNewKeyName] = useState(""); - const [newKeyExpiration, setNewKeyExpiration] = useState<"1_week" | "1_month" | "never">("1_month"); + const [newKeyExpiration, setNewKeyExpiration] = useState< + "1_week" | "1_month" | "never" + >("1_month"); const [generatedKey, setGeneratedKey] = useState(null); const [copied, setCopied] = useState(false); const { data: apiKeys, refetch: refetchKeys } = trpc.apiKeys.list.useQuery(); - const { data: stats } = trpc.apiKeys.getUsageStats.useQuery(); + const { data: keyStats } = trpc.apiKeys.getKeyStats.useQuery(); const generateMutation = trpc.apiKeys.generate.useMutation({ onSuccess: (data) => { @@ -101,76 +118,83 @@ function ApiKeysContent() { }; const handleRevoke = (keyId: string, keyName: string) => { - if (confirm(`Are you sure you want to revoke "${keyName}"? This action cannot be undone.`)) { + if ( + confirm( + `Are you sure you want to revoke "${keyName}"? This action cannot be undone.` + ) + ) { revokeMutation.mutate({ keyId }); } }; - const tier = stats?.tier || "free"; - const limits = RATE_LIMITS[tier]; - const dailyUsed = stats?.dailyUsed || 0; - const dailyRemaining = Math.max(0, limits.dailyLimit - dailyUsed); - const dailyPercentage = Math.min(100, (dailyUsed / limits.dailyLimit) * 100); - const monthlyUsed = stats?.monthlyUsed || 0; - const monthlyRemaining = Math.max(0, limits.monthlyLimit - monthlyUsed); - const monthlyPercentage = Math.min(100, (monthlyUsed / limits.monthlyLimit) * 100); - return (
-

API Keys

-

Manage your API keys and view rate limit analytics

+

+ API Keys +

+

+ Manage your API keys +

- {/* Usage Stats */} + {/* Key Stats */}
-

Rate Limit Analytics

-
+
-
- Current Tier - - {tier === "free" ? "Free" : tier === "tier_1" ? "Tier 1" : "Tier 2"} +
+
+ +
+ + Active
-

{limits.minuteLimit} requests/minute

-

{limits.dailyLimit.toLocaleString()} requests/day

-

{limits.monthlyLimit.toLocaleString()} requests/month

+ + {keyStats?.active ?? 0} +
-
- Today's Usage - {dailyUsed} / {limits.dailyLimit} -
-
-
+
+
+ +
+ + Revoked +
-

{dailyRemaining} requests remaining

+ + {keyStats?.revoked ?? 0} +
-
- Monthly Usage - {monthlyUsed.toLocaleString()} / {limits.monthlyLimit.toLocaleString()} -
-
-
+
+
+ +
+ + Expired +
-

{monthlyRemaining.toLocaleString()} requests remaining

+ + {keyStats?.expired ?? 0} +
-
- Active Keys - {stats?.activeKeyCount || 0} +
+
+ +
+ + Total Created +
+ + {keyStats?.total ?? 0} +
@@ -178,8 +202,10 @@ function ApiKeysContent() { {/* API Keys List */}
-

Your API Keys

-
- + {apiKeys.map((key: SerializedApiKey) => (
{/* Desktop Row */}
- {key.name} - {key.keyPrefix} + + {key.name} + + + {key.keyPrefix} + - {key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : "Never"} + {key.expiresAt + ? new Date(key.expiresAt).toLocaleDateString() + : "Never"} - {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : "Never"} + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleDateString() + : "Never"}
- + {/* Mobile Card */}
- {key.name} + + {key.name} +
- {key.keyPrefix} + + {key.keyPrefix} +
- Expires: {key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : "Never"} - Used: {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : "Never"} + + Expires:{" "} + {key.expiresAt + ? new Date(key.expiresAt).toLocaleDateString() + : "Never"} + + + Used:{" "} + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleDateString() + : "Never"} +
@@ -242,23 +290,35 @@ function ApiKeysContent() {
) : (
-

No API keys yet. Generate one to get started!

+

+ No API keys yet. Generate one to get started! +

)} {/* Generate Key Modal */} {showGenerateModal && ( -
-
+
e.stopPropagation()} > {!generatedKey ? ( <> -

Generate New API Key

+

+ Generate New API Key +

- +
- +
{[ { value: "1_week", label: "1 Week" }, @@ -284,7 +346,11 @@ function ApiKeysContent() { ? "bg-accent text-dark" : "bg-dark-elevated text-content-secondary border border-dark-border hover:border-accent/30" }`} - onClick={() => setNewKeyExpiration(opt.value as "1_week" | "1_month" | "never")} + onClick={() => + setNewKeyExpiration( + opt.value as "1_week" | "1_month" | "never" + ) + } > {opt.label} @@ -292,7 +358,10 @@ function ApiKeysContent() {
-
) : ( <>

- API Key Generated! + {" "} + API Key Generated!

- Save this key now! + {" "} + Save this key now! +

+

+ This is the only time you'll see this key.

-

This is the only time you'll see this key.

- {generatedKey} -
-
- Active API Keys + Minute Usage - - {stats?.activeKeyCount || 0} + + {minuteUsed} / {limits.minuteLimit}
- - Manage keys → - +
+
+
+
+

+ {minuteRemaining} remaining +

+ +
diff --git a/packages/kal-frontend/src/app/dashboard/docs/client.tsx b/packages/kal-frontend/src/app/dashboard/docs/client.tsx index e2fdbc7..e22558a 100644 --- a/packages/kal-frontend/src/app/dashboard/docs/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/docs/client.tsx @@ -238,8 +238,10 @@ function RateLimitsTab() { const tier = stats?.tier ?? "free"; const limits = RATE_LIMITS[tier]; + const minuteUsed = stats?.minuteUsed ?? 0; const dailyUsed = stats?.dailyUsed ?? 0; const monthlyUsed = stats?.monthlyUsed ?? 0; + const minutePct = Math.min(100, (minuteUsed / limits.minuteLimit) * 100); const dailyPct = Math.min(100, (dailyUsed / limits.dailyLimit) * 100); const monthlyPct = Math.min(100, (monthlyUsed / limits.monthlyLimit) * 100); @@ -286,6 +288,25 @@ function RateLimitsTab() { Your Current Usage
+
+
+ Per minute + + {minuteUsed.toLocaleString()} /{" "} + {limits.minuteLimit.toLocaleString()} + +
+
+
80 ? "bg-red-400" : minutePct > 60 ? "bg-yellow-400" : "bg-accent"}`} + style={{ width: `${minutePct}%` }} + /> +
+

+ {Math.max(0, limits.minuteLimit - minuteUsed).toLocaleString()}{" "} + remaining · resets every minute +

+
Today diff --git a/packages/kal-frontend/src/app/dashboard/foods/client.tsx b/packages/kal-frontend/src/app/dashboard/foods/client.tsx index f900cd6..057c93f 100644 --- a/packages/kal-frontend/src/app/dashboard/foods/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/foods/client.tsx @@ -54,6 +54,27 @@ const CATEGORY_GROUPS: Record = { }; // ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Build a compact page-number array with ellipsis for large page counts. */ +function getPageNumbers(current: number, total: number): (number | "...")[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + const pages: (number | "...")[] = [1]; + + if (current > 3) pages.push("..."); + + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + + if (current < total - 2) pages.push("..."); + + pages.push(total); + return pages; +} + interface FoodsClientProps { logtoId?: string; email?: string | null; @@ -360,25 +381,49 @@ function FoodsContent() { {/* Pagination */} {data && data.total > PAGE_SIZE && ( -
+
+ {/* Previous */} - - Page {currentPage} of {totalPages} - + {/* Page numbers */} + {getPageNumbers(currentPage, totalPages).map((page, i) => + page === "..." ? ( + + … + + ) : ( + + ) + )} + {/* Next */}
)} diff --git a/packages/kal-frontend/src/app/dashboard/setup/client.tsx b/packages/kal-frontend/src/app/dashboard/setup/client.tsx index 01f6b7f..7d95c85 100644 --- a/packages/kal-frontend/src/app/dashboard/setup/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/setup/client.tsx @@ -99,7 +99,8 @@ function ApiPlayground({ setResponseTime(null); const startTime = performance.now(); - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.kalori-api.my"; + const baseUrl = + process.env.NEXT_PUBLIC_API_URL || "https://api.kalori-api.my"; const url = endpoint === "halal" ? `${baseUrl}/api/v1/halal/search?q=${encodeURIComponent(query)}` @@ -354,7 +355,7 @@ const endpointsData: EndpointData[] = [ description: "Food ID (MongoDB ObjectId)", }, ], - defaultExample: "/api/v1/foods/507f1f77bcf86cd799439011", + defaultExample: "/api/v1/foods/696d6752260e86a2b61634ef", section: "natural", }, { @@ -429,7 +430,7 @@ const endpointsData: EndpointData[] = [ description: "Food ID (MongoDB ObjectId)", }, ], - defaultExample: "/api/v1/halal/507f1f77bcf86cd799439011", + defaultExample: "/api/v1/halal/696d6752260e86a2b616381b", section: "halal", }, { @@ -496,7 +497,8 @@ function InteractiveEndpoints({ apiKey }: { apiKey: string }) { setLoadingEndpoint(endpoint.id); const startTime = performance.now(); - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.kalori-api.my"; + const baseUrl = + process.env.NEXT_PUBLIC_API_URL || "https://api.kalori-api.my"; const path = getTryUrl(endpoint.id, endpoint.defaultExample); try { diff --git a/packages/kal-frontend/src/components/dashboard/Sidebar.tsx b/packages/kal-frontend/src/components/dashboard/Sidebar.tsx index b7568b9..2fa5269 100644 --- a/packages/kal-frontend/src/components/dashboard/Sidebar.tsx +++ b/packages/kal-frontend/src/components/dashboard/Sidebar.tsx @@ -10,6 +10,7 @@ import { ChevronRight, Code, Database, + FileText, Home, Key, List, @@ -175,6 +176,30 @@ export function Sidebar({ onSignOut }: SidebarProps) { })} + {/* Changelog */} +
+ setMobileOpen(false)} + className={` + flex items-center gap-3 px-4 py-3 mx-2 rounded-lg + ${ + pathname === "/dashboard/changelog" + ? "bg-accent/10 text-accent border border-accent/30" + : "text-content-secondary hover:bg-dark-elevated hover:text-content-primary" + } + `} + > + + + Changelog + + +
+ {/* Feedback */}
Review & Bug - - new -
@@ -270,6 +292,32 @@ export function Sidebar({ onSignOut }: SidebarProps) { })} + {/* Changelog */} +
+ + + {!collapsed && ( + + Changelog + + )} + +
+ {/* Feedback */}
{!collapsed && ( - <> - - Review & Bug - - - new - - + + Review & Bug + )}
diff --git a/packages/kal-frontend/tailwind.config.ts b/packages/kal-frontend/tailwind.config.ts index a04326b..4643497 100644 --- a/packages/kal-frontend/tailwind.config.ts +++ b/packages/kal-frontend/tailwind.config.ts @@ -36,6 +36,7 @@ const config: Config = { "fade-in": "fadeIn 0.5s ease-out", "slide-up": "slideUp 0.5s ease-out", "panel-slide-in": "panelSlideIn 0.25s ease-out", + "neon-pulse": "neonPulse 2s ease-in-out infinite", }, keyframes: { fadeIn: { @@ -50,6 +51,16 @@ const config: Config = { "0%": { opacity: "0", transform: "translateX(100%)" }, "100%": { opacity: "1", transform: "translateX(0)" }, }, + neonPulse: { + "0%, 100%": { + boxShadow: + "0 0 4px rgba(16,185,129,0.4), 0 0 8px rgba(16,185,129,0.2)", + }, + "50%": { + boxShadow: + "0 0 8px rgba(16,185,129,0.6), 0 0 16px rgba(16,185,129,0.3)", + }, + }, }, }, },