Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
Unsloth
df7300a3e1 Studio: harden the profile stats aggregation
Four fixes from review:

- _as_float raised OverflowError on a JSON integer wider than float, so
  one oversized counter returned 500 for the whole panel. It degrades to
  zero now, like every other unreadable field.
- Fork dedup elected one winner per source thread, but fork_chat_thread
  copies a single parent_id branch, so sibling forks of a retry and a
  regeneration hold different rows. Electing per original message keeps
  each branch when the source is deleted.
- create_run claims a resume source before the continuation logs its
  first step, so a continuation that failed early took the source's
  completed steps and tokens with it. Supersession now needs the
  continuation to have reached the source's step.
- Cumulative activity is a running total over the displayed window, but
  a narrow card trims older weeks without rebasing, opening the first
  visible bar at the hidden total and flattening the rest.
2026-07-29 02:34:24 -07:00
Unsloth
8644b4c208 Studio: drop the hour and weekday rhythm cards
Removes the "When you work" and "Your week" row from the Profile stats.

The backend stopped computing the hour and weekday buckets too, since
nothing else read them, and the payload no longer carries them. The
timezone and malformed-timestamp tests now assert on day buckets, which
is what the activity grid and streaks actually use; the daylight-saving
case moved to a timestamp where the hour of drift crosses midnight so
the day still proves it.

This was the only recharts consumer in the profile panel, so the lazy
split's note about keeping charts out of the main bundle no longer
applies and has been corrected. Build drops about 22 KB across two
fewer chunks.
2026-07-29 01:16:11 -07:00
Unsloth
485684db5f Studio: count fork clones per message and drop future streaks
- A pre-fork message can be pruned from its original thread while the
  clone stays in the fork. The suppression only checked that the source
  thread existed, so that message's tokens and activity vanished. Clones
  are now matched to the row they came from, and one fork per source is
  elected to stand in whenever the original is gone, whether the whole
  thread or just that message went.
- Future-dated history was filtered out of the current streak but still
  padded the longest streak and could be reported as the last active
  day. It is dropped before any streak field is computed.
- Cumulative mode labelled its tooltip "week of" while showing the
  running total. It now reports that week's tokens; the bar height still
  uses the running total.
- The activity grid and weekday axis formatted dates with the browser
  language rather than the language chosen in Settings, and the memos
  could not react to a change. They follow the app locale now.

The fork fixtures were also corrected: fork_chat_thread copies created_at
verbatim, so a clone carries the original's timestamp, which the previous
fixtures did not model.
2026-07-29 01:06:06 -07:00
Unsloth
f9970b8744 Studio: dedupe sibling forks and group comparison panes
- Deleting a thread that had been forked more than once left every
  sibling holding a copy of the shared ancestry, and the source-existence
  gate then let all of them count it, multiplying the usage by the number
  of forks. One fork per dead source is now elected to keep its copies,
  the one carrying the most, so nothing is lost or counted twice.
- Compare mode persists one thread per pane under a shared pair_id and
  the sidebar renders them as a single conversation. The chat total now
  keys on the pair, so one comparison is one chat and average tokens per
  chat is not halved.
- Corrected the note on cumulative mode: it is the running total across
  the displayed window, not lifetime. Seeding it with everything older
  than the window would flatten every bar against a baseline the grid
  has no room to show.
2026-07-29 00:45:12 -07:00
Unsloth
1338f09b97 Studio: harden profile stats against deleted and malformed history
- Deleting a forked conversation's source left the fork holding the only
  copies of those messages, but the skip still dropped them because
  forked_from_thread_id is not a foreign key. The suppression now joins
  the source row, so copies are only ignored while the originals survive.
- delete_run never clears the predecessor's resume_blocked, so removing
  a continuation stranded its source at zero steps and tokens while the
  run stayed visible. Supersession now requires a continuation that
  still exists.
- created_at is a client-supplied integer stored unchecked, so one
  out-of-range row made datetime raise and returned 500 for the whole
  panel. Those rows are now skipped for day and hour buckets.
- A future-dated row satisfied the current-streak window, reporting days
  that have not happened. The last active day must now be today or
  yesterday.
- Compact numbers rounded 999,999 to "1000K". Rounding into the next
  unit now steps up the suffix.
- Escape or a programmatic close unmounts the Profile tab without a
  blur, dropping an in-progress name edit. Drafts are committed on
  unmount; both saves no-op when unchanged.
- Stats copy no longer claims the numbers never leave the device, which
  is untrue when Studio is reached from another machine. It states what
  actually holds: nothing is collected or sent to Unsloth.
2026-07-29 00:10:13 -07:00
Unsloth
9ae3090b98 Studio: further profile stat corrections
Follow-up to the previous pass, each with a test that fails without it.

- Cancelling a run also sets resume_blocked, so filtering on that flag
  alone dropped the steps and tokens of cancelled work while still
  counting the run and its duration. Only a run a later run resumed
  from is excluded now: the resume claim leaves output_dir intact,
  cancelling clears it, which tells the two apart.
- A fixed getTimezoneOffset() was applied to every historical message,
  so winter records read an hour out when the panel is opened on summer
  time, moving messages near midnight onto the wrong day. The endpoint
  now takes an IANA timezone and converts each timestamp with its own
  offset, keeping the fixed offset as the fallback.
- A fork with no new turn was skipped before its thread was registered,
  so it did not appear in the chat total until its first message. The
  thread is now counted before the cloned rows are suppressed.
- Routed and aliased responses record the model that actually answered
  in responseDetails.responseModelId, while contextUsage.modelId stays
  the requested checkpoint. Most used models now prefers the former.
- Renamed runs showed the model label instead of the name the user
  chose. The name leads and the model moves beside the dataset.
2026-07-28 23:46:45 -07:00
Unsloth
6ff20ecdeb Studio: correct profile stat aggregation
Six accuracy fixes, each with a test that fails without it.

- firstTokenTime is already an elapsed duration, not a timestamp.
  Subtracting streamStartTime made the comparison false for every real
  message, so "Average time to first token" was always empty.
- Forking clones the whole ancestry into the new thread with the
  original timestamps. Those copies were counted again, doubling
  tokens, messages, attachments and activity for the source
  conversation. Rows older than the fork are now skipped.
- training_metrics.num_tokens is state.num_input_tokens_seen, a running
  total logged at each step, so summing the samples multiplied the real
  figure. Take each run's final counter, matching get_run_metrics.
- A resumed run continues its source's step and token counters from the
  checkpoint, so adding both reported the same progress twice. Only
  runs no later run resumed from are counted.
- Days, hours and weekdays were bucketed in the server's timezone while
  the client parses the keys as browser-local. The endpoint now takes
  the caller's getTimezoneOffset().
- A turn with no contextUsage.modelId fell back to the thread's
  model_id, which tracks the current selection and so misattributed
  older turns after a mid-conversation switch. Those turns are left
  uncredited instead.
2026-07-28 23:21:18 -07:00
pre-commit-ci[bot]
f9b4ca4ff0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-29 05:56:01 +00:00
Unsloth
b8364e3445 Studio: add profile usage stats and tidy the personalization panel
Adds a "Your stats" section to the Profile settings tab, built entirely
from local history in studio.db. No telemetry, nothing uploaded.

Backend
- storage/profile_stats_db.py folds every metric in one streaming pass
  over chat_messages, memoised against a (count, max created_at)
  fingerprint so reopening the tab is free until history changes.
- routes/profile_stats.py serves GET /api/profile/stats from a worker
  thread, so a cold pass cannot stall token streaming.

Frontend
- Headline tiles, a token activity grid with daily/weekly/cumulative
  modes, activity insights, most used models, hour and weekday rhythms,
  and training run totals.
- The stats panel is lazy loaded so recharts stays out of the main
  bundle.
- Personalization panel now puts a larger avatar beside the two name
  fields, with picture options moved into an edit popover.
- "Sloth in greeting" moves to Chat defaults, next to the other chat
  toggles.

Tests: 9 backend tests including a regression test that the endpoint
does not block the event loop, plus formatting unit tests.
2026-07-28 22:54:28 -07:00
26 changed files with 3476 additions and 216 deletions

View file

@ -330,6 +330,7 @@ from hub.utils.download_registry import (
)
from routes.settings import router as settings_router
from routes.prompts import router as prompts_router
from routes.profile_stats import router as profile_stats_router
from auth import storage
from auth.authentication import get_current_subject
from utils.hardware import (
@ -1048,6 +1049,7 @@ app.include_router(providers_router, prefix = "/api/providers", tags = ["provide
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
app.include_router(prompts_router, prefix = "/api/prompts", tags = ["prompts"])
app.include_router(profile_stats_router, prefix = "/api/profile", tags = ["profile"])
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
app.include_router(llama_router, prefix = "/api/llama", tags = ["llama"])

View file

@ -0,0 +1,57 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Usage numbers for the Profile settings tab.
Read-only aggregation over the local studio.db (see
``storage.profile_stats_db``). Nothing is uploaded.
"""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, Query
from auth.authentication import get_current_subject
from loggers import get_logger
from storage.profile_stats_db import (
MAX_DAILY_DAYS,
MAX_TZ_OFFSET_MINUTES,
compute_profile_stats,
)
from utils.utils import log_and_http_error
router = APIRouter()
logger = get_logger(__name__)
@router.get("/stats")
async def get_profile_stats(
days: int = Query(MAX_DAILY_DAYS, ge = 1, le = MAX_DAILY_DAYS),
tz_offset_minutes: int = Query(0, ge = -MAX_TZ_OFFSET_MINUTES, le = MAX_TZ_OFFSET_MINUTES),
tz: str = Query("", max_length = 64),
current_subject: str = Depends(get_current_subject),
) -> dict[str, Any]:
"""Usage stats for the signed-in user's local history.
Days and hours are bucketed in the caller's timezone so a remote browser
does not read the server's calendar. ``tz`` is an IANA name, which carries
each date's own daylight-saving offset; ``tz_offset_minutes`` is the
``Date.getTimezoneOffset()`` fallback for hosts with no tzdata.
"""
try:
# A cold pass parses every message's metadata JSON: ~90 ms at 10k
# messages, ~1.2 s at 260k. Off the event loop so it cannot stall token
# streaming when Settings is opened mid-generation.
return await asyncio.to_thread(
compute_profile_stats,
days = days,
tz_offset_minutes = tz_offset_minutes,
tz_name = tz,
)
except Exception as exc:
raise log_and_http_error(
exc, 500, "Failed to compute profile statistics", log = logger
) from exc

View file

@ -0,0 +1,656 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Profile usage statistics derived from studio.db.
Read-only aggregation over rows the app already writes: chat threads/messages
(with their per-message ``metadata_json``) and training runs/metrics. Nothing
is recorded specifically for stats, so the numbers are only as complete as the
local history.
Token counts live inside each message's metadata blob, so they cannot be summed
in SQL portably (JSON1 is not guaranteed on every bundled SQLite). Rows are
streamed once in (thread, time) order and every metric is folded in that single
pass, then memoised against a (count, max created_at) fingerprint so reopening
the Profile tab is free until history changes.
"""
import json
import threading
import time
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from typing import Any, Optional
from loggers import get_logger
from storage.studio_db import get_connection
logger = get_logger(__name__)
# Gaps longer than this end a "sitting at the keyboard" stretch: without the cap
# a thread reopened a week later would report a week-long chat.
SESSION_GAP_SECONDS = 30 * 60
# Cap on the daily activity series handed to the UI (the heatmap draws a year).
MAX_DAILY_DAYS = 366
# Widest real UTC offset is 14h; anything beyond that is a bad client value.
MAX_TZ_OFFSET_MINUTES = 14 * 60
# Top-N lists returned to the client.
TOP_MODELS = 8
RECENT_RUNS = 5
# Serve a memoised payload for this long even if the fingerprint is unchanged,
# so a chat that is mid-stream still refreshes reasonably promptly.
CACHE_TTL_SECONDS = 20.0
_cache_lock = threading.Lock()
_cache: dict[str, Any] = {"fingerprint": None, "expires_at": 0.0, "payload": None}
def _as_float(value: Any) -> Optional[float]:
"""Coerce JSON numbers defensively; metadata is written by the client.
json accepts integers of any width, and float() raises OverflowError past
~1e308, so one oversized counter would 500 the whole panel.
"""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
try:
number = float(value)
except (OverflowError, ValueError):
return None
return number if number == number and number not in (float("inf"), float("-inf")) else None
return None
def _as_int(value: Any) -> int:
number = _as_float(value)
if number is None or number < 0:
return 0
return int(number)
def _iso(day: date) -> str:
return day.isoformat()
def _clean_str(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _resolve_zone(tz_name: str, tz_offset_minutes: int):
"""The caller's zone, preferring an IANA name over a single offset.
A fixed offset is only correct for the half of the year the caller happens
to be in, so a winter message read during summer lands an hour out and can
cross midnight. An IANA name carries each date's own offset. The offset
stays as the fallback for callers that send no name, or hosts with no tzdata.
"""
if tz_name:
try:
return ZoneInfo(tz_name)
except (ValueError, ZoneInfoNotFoundError, OSError):
logger.debug("unknown timezone %r, falling back to fixed offset", tz_name)
return timezone(timedelta(minutes = -tz_offset_minutes))
def _local_stamp(created_at_ms: int, zone) -> Optional[datetime]:
"""Wall-clock time in the caller's timezone, not the server's.
created_at is a client-supplied integer that SQLite stores unchecked, so a
value outside datetime's range is possible. Drop that row rather than let
one bad import take down the whole panel.
"""
if created_at_ms <= 0:
return None
try:
return datetime.fromtimestamp(created_at_ms / 1000, tz = zone).replace(tzinfo = None)
except (ValueError, OverflowError, OSError):
return None
def _streaks(days: set[date], today: date) -> dict[str, Any]:
"""Current and longest run of consecutive active days.
The current streak survives a day that has not been used yet: a streak that
ended yesterday is still "live" until today is over.
Imported history or a skewed client clock can date rows in the future.
Those are dropped up front so they cannot pad the longest streak or be
reported as the last active day either.
"""
days = {day for day in days if day <= today}
if not days:
return {"current": 0, "longest": 0, "lastActiveDay": None}
ordered = sorted(days)
longest = 1
running = 1
for previous, current in zip(ordered, ordered[1:]):
running = running + 1 if current - previous == timedelta(days = 1) else 1
longest = max(longest, running)
last = ordered[-1]
current_streak = 0
if today - last <= timedelta(days = 1):
current_streak = 1
cursor = last
while cursor - timedelta(days = 1) in days:
cursor -= timedelta(days = 1)
current_streak += 1
return {"current": current_streak, "longest": longest, "lastActiveDay": _iso(last)}
def _model_label(model_id: str) -> str:
"""Last path segment of a repo id, e.g. ``unsloth/gpt-oss-20b`` -> ``gpt-oss-20b``."""
cleaned = model_id.strip().replace("\\", "/")
tail = cleaned.rstrip("/").split("/")[-1]
return tail or cleaned
class _MessageFold:
"""Accumulators for the single streaming pass over chat messages."""
def __init__(self) -> None:
self.threads: set[str] = set()
self.messages = 0
self.user_messages = 0
self.assistant_messages = 0
self.prompt_tokens = 0
self.completion_tokens = 0
self.total_tokens = 0
self.cached_tokens = 0
self.tool_calls = 0
self.attachments = 0
self.session_seconds = 0.0
self.longest_chat: dict[str, Any] = {
"threadId": None,
"title": None,
"seconds": 0.0,
"messages": 0,
}
self.by_day: dict[date, dict[str, Any]] = {}
self.models: dict[str, dict[str, Any]] = {}
self.speed_samples: list[float] = []
self.best_speed = 0.0
self.best_speed_model: Optional[str] = None
self.response_ms: list[float] = []
self.first_token_ms: list[float] = []
def note_model(self, model_id: str, tokens: int) -> None:
entry = self.models.setdefault(
model_id, {"id": model_id, "label": _model_label(model_id), "messages": 0, "tokens": 0}
)
entry["messages"] += 1
entry["tokens"] += tokens
def note_day(self, day: date, tokens: int, thread_id: str) -> None:
bucket = self.by_day.setdefault(day, {"tokens": 0, "messages": 0, "threads": set()})
bucket["tokens"] += tokens
bucket["messages"] += 1
bucket["threads"].add(thread_id)
def _fork_keepers(conn) -> dict[tuple[str, int, str], str]:
"""For each original message, the one clone elected to stand in for it.
A clone is normally ignored because the original is counted instead. Once
the original is gone, whether its thread was deleted or just that row was
pruned, the clones become the only record. Letting every sibling count them
would multiply the usage, so exactly one may.
Electing per message rather than per fork matters because fork_chat_thread
copies one parent_id branch, not the whole thread: sibling forks taken from
a retry and a regeneration hold different rows, and a per-fork winner would
silently drop whatever only the loser carries.
"""
rows = conn.execute(
"""
SELECT m.thread_id, m.created_at, m.role,
t.forked_from_thread_id AS source_id
FROM chat_messages m
JOIN chat_threads t ON t.id = m.thread_id
WHERE t.forked_from_thread_id IS NOT NULL
AND m.created_at < t.created_at
"""
)
best: dict[tuple[str, int, str], str] = {}
for row in rows:
key = (row["source_id"], _as_int(row["created_at"]), row["role"])
thread_id = row["thread_id"]
current = best.get(key)
# Any stable winner works; lowest id keeps the choice reproducible.
if current is None or thread_id < current:
best[key] = thread_id
return best
def _surviving_original_keys(conn) -> set[tuple[str, int, str]]:
"""Identity of every message still living in a thread that has been forked.
Clones get fresh ids, so there is nothing to join on. Within one thread the
timestamp and role are enough to recognise the row a clone was taken from.
"""
rows = conn.execute(
"""
SELECT m.thread_id, m.created_at, m.role
FROM chat_messages m
WHERE m.thread_id IN (
SELECT DISTINCT forked_from_thread_id FROM chat_threads
WHERE forked_from_thread_id IS NOT NULL
)
"""
)
return {(row["thread_id"], _as_int(row["created_at"]), row["role"]) for row in rows}
def _fold_messages(conn, zone) -> _MessageFold:
fold = _MessageFold()
keepers = _fork_keepers(conn)
surviving = _surviving_original_keys(conn)
rows = conn.execute(
"""
SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at,
t.title, t.model_id, t.model_type, t.pair_id,
t.created_at AS thread_created_at, t.forked_from_thread_id
FROM chat_messages m
LEFT JOIN chat_threads t ON t.id = m.thread_id
ORDER BY m.thread_id, m.created_at
"""
)
current_thread: Optional[str] = None
thread_title: Optional[str] = None
thread_seconds = 0.0
thread_messages = 0
previous_created: Optional[int] = None
def close_thread() -> None:
if current_thread is None:
return
fold.session_seconds += thread_seconds
if thread_seconds > fold.longest_chat["seconds"]:
fold.longest_chat = {
"threadId": current_thread,
"title": thread_title,
"seconds": thread_seconds,
"messages": thread_messages,
}
for row in rows:
thread_id = row["thread_id"]
created_at = _as_int(row["created_at"])
if thread_id != current_thread:
close_thread()
current_thread = thread_id
thread_title = row["title"]
thread_seconds = 0.0
thread_messages = 0
previous_created = None
# Compare mode stores one thread per pane under a shared pair_id, and
# the sidebar shows them as a single conversation. Count them that way
# too, or one comparison inflates the chat total and drags the average
# tokens per chat down.
conversation_id = row["pair_id"] or thread_id
# A fork is its own visible conversation, so it counts towards the chat
# total from the moment it exists, before any new turn is added.
fold.threads.add(conversation_id)
# Forking clones the whole ancestry into the new thread, keeping each
# copy's original timestamp. Skip a clone while the row it was taken
# from is still there to be counted; once that original is gone, only
# the elected fork stands in for it, so nothing is lost or doubled.
source_id = row["forked_from_thread_id"]
if source_id and created_at < _as_int(row["thread_created_at"]):
original = (source_id, created_at, row["role"])
if original in surviving or keepers.get(original) != thread_id:
continue
fold.messages += 1
thread_messages += 1
if previous_created is not None:
gap = (created_at - previous_created) / 1000
if 0 < gap <= SESSION_GAP_SECONDS:
thread_seconds += gap
previous_created = created_at
stamp = _local_stamp(created_at, zone)
role = row["role"]
if role == "user":
fold.user_messages += 1
attachments_json = row["attachments_json"]
if attachments_json:
try:
parsed = json.loads(attachments_json)
if isinstance(parsed, list):
fold.attachments += len(parsed)
except (json.JSONDecodeError, TypeError):
pass
message_tokens = 0
metadata: Any = None
if role == "assistant":
fold.assistant_messages += 1
raw_metadata = row["metadata_json"]
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except (json.JSONDecodeError, TypeError):
metadata = None
if isinstance(metadata, dict):
usage = metadata.get("contextUsage")
timing = metadata.get("timing")
usage = usage if isinstance(usage, dict) else {}
timing = timing if isinstance(timing, dict) else {}
prompt_tokens = _as_int(usage.get("promptTokens"))
completion_tokens = _as_int(usage.get("completionTokens"))
total_tokens = _as_int(usage.get("totalTokens"))
if completion_tokens == 0:
# Local engines occasionally omit the usage chunk; the adapter's
# own token count is the next best estimate.
completion_tokens = _as_int(timing.get("tokenCount"))
if total_tokens == 0:
total_tokens = prompt_tokens + completion_tokens
fold.prompt_tokens += prompt_tokens
fold.completion_tokens += completion_tokens
fold.total_tokens += total_tokens
fold.cached_tokens += _as_int(usage.get("cachedTokens"))
fold.tool_calls += _as_int(timing.get("toolCallCount"))
message_tokens = total_tokens
# responseDetails carries the model that actually answered, which
# differs from the requested checkpoint whenever a provider routes
# or resolves an alias. contextUsage.modelId is the request, so it
# is only the fallback. The thread's model_id is never used: it
# tracks the current selection, not the one that ran.
details = metadata.get("responseDetails")
details = details if isinstance(details, dict) else {}
model_id = _clean_str(details.get("responseModelId")) or _clean_str(
usage.get("modelId")
)
if model_id:
fold.note_model(model_id, message_tokens)
speed = _as_float(timing.get("tokensPerSecond"))
# llama.cpp reports absurd rates on no-op turns; ignore those.
if speed is not None and 0 < speed < 100_000:
fold.speed_samples.append(speed)
if speed > fold.best_speed:
fold.best_speed = speed
fold.best_speed_model = _model_label(model_id) if model_id else None
stream_ms = _as_float(timing.get("totalStreamTime"))
if stream_ms is not None and stream_ms > 0:
fold.response_ms.append(stream_ms)
# firstTokenTime is already an elapsed duration, not a timestamp.
first_token = _as_float(timing.get("firstTokenTime"))
if first_token is not None and first_token > 0:
fold.first_token_ms.append(first_token)
if stamp is not None:
fold.note_day(stamp.date(), message_tokens, conversation_id)
close_thread()
return fold
def _daily_series(fold: _MessageFold, today: date, days: int) -> list[dict[str, Any]]:
"""Dense day-by-day series so the heatmap can index straight into it."""
start = today - timedelta(days = days - 1)
series: list[dict[str, Any]] = []
for offset in range(days):
day = start + timedelta(days = offset)
bucket = fold.by_day.get(day)
series.append(
{
"date": _iso(day),
"tokens": int(bucket["tokens"]) if bucket else 0,
"messages": int(bucket["messages"]) if bucket else 0,
"chats": len(bucket["threads"]) if bucket else 0,
}
)
return series
def _superseded(prefix: str = "r.") -> str:
"""SQL for "a later run resumed from this one, so its counters live there".
``prefix`` must qualify the outer row: the EXISTS subquery selects from the
same table, so a bare column name would bind to the subquery instead.
``create_run``'s resume claim sets ``resume_blocked`` and leaves
``output_dir`` alone. Cancelling clears ``output_dir`` while setting the
same flag, so the flag alone cannot tell the two apart.
``delete_run`` never clears the flag, so the continuation has to still be
there. Otherwise deleting it would strand the source at zero while its row
and metrics stay visible in history.
The continuation also has to have reached the source's step. ``create_run``
claims the source the moment a resume starts, but ``final_step`` is only
written on the first metric flush, so a continuation that fails before then
would take the source's completed work down with it.
"""
return f"""
{prefix}resume_blocked = 1
AND {prefix}output_dir IS NOT NULL
AND EXISTS (
SELECT 1 FROM training_runs continuation
WHERE continuation.output_dir = {prefix}output_dir
AND continuation.started_at > {prefix}started_at
AND COALESCE(continuation.final_step, 0)
>= COALESCE({prefix}final_step, 0)
)
"""
def _training_stats(conn) -> dict[str, Any]:
row = conn.execute(
"""
SELECT COUNT(*) AS runs,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(COALESCE(duration_seconds, 0)) AS seconds,
COUNT(DISTINCT model_name) AS models,
COUNT(DISTINCT dataset_name) AS datasets,
MIN(final_loss) AS best_loss
FROM training_runs
"""
).fetchone()
# A resumed run continues its source's step and token counters from the
# checkpoint, so both absolute totals already include the source's work.
# Only a run superseded by a resume is dropped: create_run's claim sets
# resume_blocked while leaving output_dir intact, whereas cancelling clears
# output_dir, so a cancelled run keeps contributing the work it did do.
steps = conn.execute(
f"SELECT COALESCE(SUM(r.final_step), 0) FROM training_runs r WHERE NOT ({_superseded()})"
).fetchone()[0]
# num_tokens is state.num_input_tokens_seen, a running total logged at each
# step, so summing the samples multiplies the real figure. Take each run's
# final counter, the same value get_run_metrics reports.
tokens = conn.execute(
f"""
SELECT COALESCE(SUM(run_tokens), 0) FROM (
SELECT MAX(m.num_tokens) AS run_tokens
FROM training_metrics m
JOIN training_runs r ON r.id = m.run_id
WHERE NOT ({_superseded("r.")})
GROUP BY m.run_id
)
"""
).fetchone()[0]
recent = conn.execute(
"""
SELECT id, display_name, model_name, dataset_name,
status, final_loss, final_step, duration_seconds, started_at
FROM training_runs
ORDER BY started_at DESC
LIMIT ?
""",
(RECENT_RUNS,),
).fetchall()
return {
"runs": _as_int(row["runs"]),
"completed": _as_int(row["completed"]),
"steps": _as_int(steps),
"tokens": _as_int(tokens),
"seconds": _as_float(row["seconds"]) or 0.0,
"models": _as_int(row["models"]),
"datasets": _as_int(row["datasets"]),
"bestLoss": _as_float(row["best_loss"]),
"recent": [
{
"id": item["id"],
# A renamed run keeps the name the user gave it; otherwise fall
# back to the short model label rather than the full repo id.
"name": _clean_str(item["display_name"]) or _model_label(item["model_name"] or ""),
"modelLabel": _model_label(item["model_name"] or ""),
"datasetLabel": _model_label(item["dataset_name"] or ""),
"status": item["status"],
"finalLoss": _as_float(item["final_loss"]),
"steps": _as_int(item["final_step"]),
"seconds": _as_float(item["duration_seconds"]) or 0.0,
"startedAt": item["started_at"],
}
for item in recent
],
}
def _fingerprint(conn) -> tuple:
message_row = conn.execute(
"SELECT COUNT(*), COALESCE(MAX(created_at), 0) FROM chat_messages"
).fetchone()
run_row = conn.execute(
"SELECT COUNT(*), COALESCE(MAX(started_at), '') FROM training_runs"
).fetchone()
return (message_row[0], message_row[1], run_row[0], run_row[1])
def compute_profile_stats(
days: int = MAX_DAILY_DAYS,
tz_offset_minutes: int = 0,
tz_name: str = "",
) -> dict[str, Any]:
"""Aggregate every profile statistic in one pass, memoised per history state."""
days = max(1, min(int(days), MAX_DAILY_DAYS))
tz_offset_minutes = max(
-MAX_TZ_OFFSET_MINUTES, min(int(tz_offset_minutes), MAX_TZ_OFFSET_MINUTES)
)
zone = _resolve_zone(tz_name, tz_offset_minutes)
conn = get_connection()
try:
fingerprint = (_fingerprint(conn), days, tz_offset_minutes, tz_name)
now = time.monotonic()
with _cache_lock:
if (
_cache["payload"] is not None
and _cache["fingerprint"] == fingerprint
and _cache["expires_at"] > now
):
return _cache["payload"]
started = time.perf_counter()
fold = _fold_messages(conn, zone)
training = _training_stats(conn)
# "Today" has to match the buckets above, or the newest column and the
# current streak drift by a day whenever the caller is elsewhere.
today = (_local_stamp(int(time.time() * 1000), zone) or datetime.now()).date()
streak = _streaks(set(fold.by_day.keys()), today)
daily = _daily_series(fold, today, days)
peak_day = max(fold.by_day.items(), key = lambda item: item[1]["tokens"], default = None)
models = sorted(
fold.models.values(), key = lambda item: (item["tokens"], item["messages"]), reverse = True
)[:TOP_MODELS]
speed_samples = fold.speed_samples
payload = {
"generatedAt": int(time.time() * 1000),
"days": days,
"totals": {
"threads": len(fold.threads),
"messages": fold.messages,
"userMessages": fold.user_messages,
"assistantMessages": fold.assistant_messages,
"promptTokens": fold.prompt_tokens,
"completionTokens": fold.completion_tokens,
"totalTokens": fold.total_tokens,
"cachedTokens": fold.cached_tokens,
"toolCalls": fold.tool_calls,
"attachments": fold.attachments,
"activeDays": len(fold.by_day),
"chatSeconds": round(fold.session_seconds),
},
"streak": streak,
"peakDay": (
{"date": _iso(peak_day[0]), "tokens": int(peak_day[1]["tokens"])}
if peak_day and peak_day[1]["tokens"] > 0
else None
),
"longestChat": (
{
"threadId": fold.longest_chat["threadId"],
"title": fold.longest_chat["title"],
"seconds": round(fold.longest_chat["seconds"]),
"messages": fold.longest_chat["messages"],
}
if fold.longest_chat["seconds"] > 0
else None
),
"daily": daily,
"models": models,
"speed": {
"averageTokensPerSecond": (
sum(speed_samples) / len(speed_samples) if speed_samples else None
),
"bestTokensPerSecond": fold.best_speed or None,
"bestTokensPerSecondModel": fold.best_speed_model,
"averageResponseMs": (
sum(fold.response_ms) / len(fold.response_ms) if fold.response_ms else None
),
"averageFirstTokenMs": (
sum(fold.first_token_ms) / len(fold.first_token_ms)
if fold.first_token_ms
else None
),
"samples": len(speed_samples),
},
"training": training,
}
logger.debug(
"profile stats computed in %.1f ms (%d messages)",
(time.perf_counter() - started) * 1000,
fold.messages,
)
with _cache_lock:
_cache["fingerprint"] = fingerprint
_cache["expires_at"] = time.monotonic() + CACHE_TTL_SECONDS
_cache["payload"] = payload
return payload
finally:
conn.close()
def invalidate_profile_stats_cache() -> None:
"""Drop the memoised payload (used by tests and after history wipes)."""
with _cache_lock:
_cache["fingerprint"] = None
_cache["expires_at"] = 0.0
_cache["payload"] = None

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
/** One day of the activity series. Dense: every day in range is present. */
export type ProfileStatsDay = {
date: string;
tokens: number;
messages: number;
chats: number;
};
export type ProfileStatsModel = {
id: string;
label: string;
messages: number;
tokens: number;
};
export type ProfileStatsRun = {
id: string;
name: string;
modelLabel: string;
datasetLabel: string;
status: string;
finalLoss: number | null;
steps: number;
seconds: number;
startedAt: string | null;
};
export type ProfileStats = {
generatedAt: number;
days: number;
totals: {
threads: number;
messages: number;
userMessages: number;
assistantMessages: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cachedTokens: number;
toolCalls: number;
attachments: number;
activeDays: number;
chatSeconds: number;
};
streak: {
current: number;
longest: number;
lastActiveDay: string | null;
};
peakDay: { date: string; tokens: number } | null;
longestChat: {
threadId: string | null;
title: string | null;
seconds: number;
messages: number;
} | null;
daily: ProfileStatsDay[];
models: ProfileStatsModel[];
speed: {
averageTokensPerSecond: number | null;
bestTokensPerSecond: number | null;
bestTokensPerSecondModel: string | null;
averageResponseMs: number | null;
averageFirstTokenMs: number | null;
samples: number;
};
training: {
runs: number;
completed: number;
steps: number;
tokens: number;
seconds: number;
models: number;
datasets: number;
bestLoss: number | null;
recent: ProfileStatsRun[];
};
};
export async function loadProfileStats(
signal?: AbortSignal,
): Promise<ProfileStats> {
// Bucket days and hours in this browser's timezone, which is not the
// server's when Studio is reached over the network. The IANA name is what
// gives each historical date its own daylight-saving offset; the current
// offset only covers callers whose host cannot resolve the name.
const query = new URLSearchParams({
tz_offset_minutes: String(new Date().getTimezoneOffset()),
tz: Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
});
const res = await authFetch(`/api/profile/stats?${query}`, { signal });
if (!res.ok) {
throw new Error(await readFastApiError(res, "Failed to load your stats"));
}
return (await res.json()) as ProfileStats;
}

View file

@ -5,14 +5,23 @@ import { publicAssetUrl } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { getAuthToken } from "@/features/auth";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { toastError, toastSuccess } from "@/shared/toast";
import { Edit03Icon } from "@hugeicons/core-free-icons";
import {
Delete02Icon,
Edit03Icon,
Image01Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SLOTH_AVATARS } from "../sloth-avatars";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
@ -23,6 +32,8 @@ import {
import { UserAvatar } from "./user-avatar";
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
const SLOTH_NAME = /^large\s+/i;
const PNG_SUFFIX = /\.png$/i;
function readPersistedProfile(): {
displayName: string;
@ -36,7 +47,8 @@ function readPersistedProfile(): {
if (!parsed || typeof parsed !== "object") return null;
// Zustand persist shape: { state: {...}, version }
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
const maybeState =
"state" in parsed ? (parsed as { state?: unknown }).state : parsed;
if (!maybeState || typeof maybeState !== "object") return null;
const state = maybeState as {
displayName?: unknown;
@ -45,9 +57,11 @@ function readPersistedProfile(): {
};
return {
displayName: typeof state.displayName === "string" ? state.displayName : "",
displayName:
typeof state.displayName === "string" ? state.displayName : "",
nickname: typeof state.nickname === "string" ? state.nickname : "",
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
avatarDataUrl:
typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
};
} catch {
return null;
@ -64,28 +78,17 @@ export function ProfilePersonalizationPanel() {
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const setShowGreetingSloth = useUserProfileStore(
(s) => s.setShowGreetingSloth,
);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
const [draftNickname, setDraftNickname] = useState(nickname);
const [pickerOpen, setPickerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const lastDisplayNameRef = useRef(displayName);
const lastNicknameRef = useRef(nickname);
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
const previewName = draftName.trim() || sessionSub || "Unsloth";
const hasNameChanges = useMemo(
() => draftName.trim() !== displayName.trim(),
[draftName, displayName],
);
const hasNicknameChanges = useMemo(
() => draftNickname.trim() !== nickname.trim(),
[draftNickname, nickname],
);
useEffect(() => {
const previous = lastDisplayNameRef.current;
@ -99,6 +102,8 @@ export function ProfilePersonalizationPanel() {
setDraftNickname((draft) => (draft === previous ? nickname : draft));
}, [nickname]);
// Committed on blur and on Enter rather than behind a Save button, so each
// field is a single row like the rest of Settings.
const saveName = () => {
const trimmed = draftName.trim();
if (trimmed !== draftName) setDraftName(trimmed);
@ -133,6 +138,18 @@ export function ProfilePersonalizationPanel() {
}
};
// Escape, or any programmatic close, unmounts the tab without dispatching a
// blur, which would drop whatever was typed. Commit the drafts on the way
// out; both saves no-op on an unchanged value, so a double commit is safe.
const flushDrafts = useRef<() => void>(() => {});
useEffect(() => {
flushDrafts.current = () => {
saveName();
saveNickname();
};
});
useEffect(() => () => flushDrafts.current(), []);
const applyAvatar = (value: string | null) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();
@ -180,201 +197,232 @@ export function ProfilePersonalizationPanel() {
requestAnimationFrame(() => applyAvatar(value));
};
const pickSloth = (path: string) => {
pickAvatarValue(publicAssetUrl(path));
};
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
<div className="relative">
<UserAvatar
name={previewName}
imageUrl={shownAvatar}
size="lg"
className="size-[124px] text-[calc(3.15rem*var(--ui-font-scale,1))]"
/>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
void onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("settings.profile.changePicture")}
>
<HugeiconsIcon icon={Edit03Icon} className="size-3.5" strokeWidth={2} />
</button>
</div>
<div className="flex w-full flex-col">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
void onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
<div
data-settings-label={t("settings.profile.displayName")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.displayName")}
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-display-name"
type="text"
value={draftName}
maxLength={PROFILE_TEXT_MAX_LENGTH}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
}
}}
autoComplete="off"
placeholder={sessionSub || "Unsloth"}
className="h-10 min-w-0 flex-1 rounded-full text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
{t("common.save")}
</Button>
</div>
</div>
<div
data-settings-label={t("settings.profile.nickname")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.nickname")}
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-nickname"
type="text"
value={draftNickname}
maxLength={PROFILE_TEXT_MAX_LENGTH}
onChange={(e) => setDraftNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveNickname();
}
}}
autoComplete="off"
placeholder={t("settings.profile.nicknamePlaceholder")}
className="h-10 min-w-0 flex-1 rounded-full text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveNickname} disabled={!hasNicknameChanges}>
{t("common.save")}
</Button>
</div>
</div>
<div
data-settings-label={t("settings.profile.avatarShape")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.avatarShape")}
</Label>
<div className="hub-tab-toggle inline-flex h-8 w-fit items-center rounded-full">
{(["circle", "rounded"] as const).map((shape) => (
<button
key={shape}
type="button"
onClick={() => setAvatarShape(shape)}
aria-pressed={avatarShape === shape}
className={cn(
"inline-flex h-8 items-center rounded-full px-4 text-ui-13 font-medium transition-colors",
avatarShape === shape
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{shape === "circle"
? t("settings.profile.avatarShapeCircle")
: t("settings.profile.avatarShapeRounded")}
</button>
))}
</div>
</div>
<div
data-settings-label={t("settings.profile.greetingSloth")}
className="flex w-full max-w-[560px] items-center justify-between gap-4"
>
<div className="flex min-w-0 flex-col gap-0.5">
<Label
htmlFor="profile-greeting-sloth"
className="text-xs font-medium text-muted-foreground"
>
{t("settings.profile.greetingSloth")}
</Label>
<p className="text-xs text-muted-foreground/75">
{t("settings.profile.greetingSlothDescription")}
</p>
</div>
<Switch
id="profile-greeting-sloth"
checked={showGreetingSloth}
onCheckedChange={setShowGreetingSloth}
/>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.chooseSloth")}
</Label>
<div className="grid grid-cols-7 gap-2 sm:grid-cols-9">
{SLOTH_AVATARS.map((path) => {
const url = publicAssetUrl(path);
const selected = shownAvatar === url;
const label =
path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ??
"sloth";
return (
<button
key={path}
type="button"
onClick={() => pickSloth(path)}
aria-pressed={selected}
aria-label={label}
title={label}
className={cn(
// No transition here: animating the ring makes the old
// icon's selection border linger when switching sloths.
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
// Selection keeps the 1px weight, only darker.
selected && "ring-ring-strong hover:ring-ring-strong",
)}
>
<img src={url} alt="" loading="lazy" className="size-full object-cover" />
</button>
);
})}
<div className="flex items-center gap-10 py-6 pr-2">
<div className="relative shrink-0">
{/* The picture itself is the shortcut to "upload a photo"; the pencil
opens the rest of the options. */}
<button
type="button"
onClick={() => pickAvatarValue(null)}
aria-pressed={shownAvatar === null}
aria-label={t("settings.profile.noPicture")}
title={t("settings.profile.noPicture")}
className={cn(
"relative flex aspect-square items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
shownAvatar === null && "ring-ring-strong hover:ring-ring-strong",
)}
onClick={() => fileInputRef.current?.click()}
aria-label={t("settings.profile.changePicture")}
className="group relative block rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span className="text-ui-11 font-medium">
{t("settings.profile.noneLabel")}
<UserAvatar
name={previewName}
imageUrl={shownAvatar}
size="lg"
className="size-[128px] text-[calc(3.2rem*var(--ui-font-scale,1))]"
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/45 opacity-0 transition-opacity group-hover:opacity-100">
<HugeiconsIcon
icon={Image01Icon}
className="size-8 text-white"
strokeWidth={2}
/>
</span>
</button>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild={true}>
<button
type="button"
aria-label={t("settings.profile.pictureOptions")}
title={t("settings.profile.pictureOptions")}
className="absolute top-[85.36%] left-[85.36%] flex size-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:border-transparent dark:bg-white/[0.14] dark:hover:bg-white/20"
>
<HugeiconsIcon
icon={Edit03Icon}
className="size-4.5"
strokeWidth={2}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={10}
className="w-[320px] gap-4 p-4"
>
<div className="flex items-center justify-between gap-3">
<span className="text-ui-11 font-medium uppercase tracking-wide text-muted-foreground">
{t("settings.profile.avatarShape")}
</span>
<div className="hub-tab-toggle flex h-8 shrink-0 items-center rounded-full">
{(["circle", "rounded"] as const).map((shape) => (
<button
key={shape}
type="button"
onClick={() => setAvatarShape(shape)}
aria-pressed={avatarShape === shape}
className={cn(
"inline-flex h-8 items-center justify-center rounded-full px-3.5 text-ui-13 font-medium transition-colors",
avatarShape === shape
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{shape === "circle"
? t("settings.profile.avatarShapeCircle")
: t("settings.profile.avatarShapeRounded")}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="h-9 w-fit gap-2 rounded-full px-4 text-sm"
>
<HugeiconsIcon
icon={Upload01Icon}
className="size-4"
strokeWidth={2}
/>
{t("settings.profile.uploadPhoto")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => pickAvatarValue(null)}
disabled={shownAvatar === null}
aria-label={t("settings.profile.removePhoto")}
title={t("settings.profile.removePhoto")}
className="size-9 shrink-0 rounded-full p-0 text-muted-foreground"
>
<HugeiconsIcon
icon={Delete02Icon}
className="size-4"
strokeWidth={2}
/>
</Button>
</div>
<div className="flex flex-col gap-2">
<span className="text-ui-11 font-medium uppercase tracking-wide text-muted-foreground">
{t("settings.profile.chooseSloth")}
</span>
<div className="grid grid-cols-7 gap-2">
{SLOTH_AVATARS.map((path) => {
const url = publicAssetUrl(path);
const selected = shownAvatar === url;
const label =
path
.split("/")
.pop()
?.replace(PNG_SUFFIX, "")
.replace(SLOTH_NAME, "")
.trim() ?? "sloth";
return (
<button
key={path}
type="button"
onClick={() => pickAvatarValue(url)}
aria-pressed={selected}
aria-label={label}
title={label}
className={cn(
// No transition here: animating the ring makes the old
// icon's selection border linger when switching sloths.
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
selected &&
"ring-2 ring-ring-strong hover:ring-ring-strong",
)}
>
<img
src={url}
alt=""
loading="lazy"
className="size-full object-cover"
/>
</button>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Name fields sit beside the picture. These are not SettingsRows, so
data-settings-label is set by hand for settings search. */}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div
data-settings-label={t("settings.profile.displayName")}
className="flex min-w-0 flex-col gap-1.5"
>
<Label
htmlFor="profile-display-name"
className="text-xs font-medium text-muted-foreground"
>
{t("settings.profile.displayName")}
</Label>
<Input
id="profile-display-name"
type="text"
value={draftName}
maxLength={PROFILE_TEXT_MAX_LENGTH}
onChange={(e) => setDraftName(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
autoComplete="off"
placeholder={sessionSub || "Unsloth"}
className="h-9 w-full rounded-full text-sm"
/>
</div>
<div
data-settings-label={t("settings.profile.nickname")}
className="flex min-w-0 flex-col gap-1.5"
>
<Label
htmlFor="profile-nickname"
className="text-xs font-medium text-muted-foreground"
>
{t("settings.profile.nickname")}
</Label>
<Input
id="profile-nickname"
type="text"
value={draftNickname}
maxLength={PROFILE_TEXT_MAX_LENGTH}
onChange={(e) => setDraftNickname(e.target.value)}
onBlur={saveNickname}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
autoComplete="off"
placeholder={t("settings.profile.nicknamePlaceholder")}
className="h-9 w-full rounded-full text-sm"
/>
</div>
</div>
</div>
{imageError ? (
<p className="w-full text-xs text-destructive" role="alert">
<p className="pt-2 text-xs text-destructive" role="alert">
{imageError}
</p>
) : null}

View file

@ -0,0 +1,151 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useT } from "@/i18n";
import type { ProfileStats } from "../../api/profile-stats";
import {
formatCompactNumber,
formatDuration,
formatFullNumber,
formatMilliseconds,
} from "../../utils/stats-format";
import { StatMeter, StatRow, StatsCard } from "./stat-primitives";
/** Left column: the "how you use Unsloth" numbers. */
export function ActivityInsightsCard({ stats }: { stats: ProfileStats }) {
const t = useT();
const { totals, speed } = stats;
const averageTokensPerChat =
totals.threads > 0 ? totals.totalTokens / totals.threads : 0;
const cacheShare =
totals.promptTokens > 0 ? totals.cachedTokens / totals.promptTokens : 0;
return (
<StatsCard title={t("settings.profile.stats.insightsTitle")}>
<div className="flex flex-col divide-y divide-border/60">
<StatRow
label={t("settings.profile.stats.totalChats")}
value={formatFullNumber(totals.threads)}
/>
<StatRow
label={t("settings.profile.stats.totalMessages")}
value={formatFullNumber(totals.messages)}
/>
<StatRow
label={t("settings.profile.stats.tokensIn")}
value={formatCompactNumber(totals.promptTokens)}
/>
<StatRow
label={t("settings.profile.stats.tokensOut")}
value={formatCompactNumber(totals.completionTokens)}
/>
<StatRow
label={t("settings.profile.stats.cachedTokens")}
value={
cacheShare > 0
? t("settings.profile.stats.cachedValue", {
tokens: formatCompactNumber(totals.cachedTokens),
percent: Math.round(cacheShare * 100),
})
: formatCompactNumber(totals.cachedTokens)
}
/>
<StatRow
label={t("settings.profile.stats.avgTokensPerChat")}
value={formatCompactNumber(averageTokensPerChat)}
/>
<StatRow
label={t("settings.profile.stats.timeInChat")}
value={formatDuration(totals.chatSeconds)}
/>
<StatRow
label={t("settings.profile.stats.activeDays")}
value={formatFullNumber(totals.activeDays)}
/>
<StatRow
label={t("settings.profile.stats.toolCalls")}
value={formatFullNumber(totals.toolCalls)}
/>
<StatRow
label={t("settings.profile.stats.attachments")}
value={formatFullNumber(totals.attachments)}
/>
<StatRow
label={t("settings.profile.stats.avgSpeed")}
value={
speed.averageTokensPerSecond === null
? "—"
: t("settings.profile.stats.tokensPerSecond", {
value: speed.averageTokensPerSecond.toFixed(1),
})
}
/>
<StatRow
label={t("settings.profile.stats.bestSpeed")}
value={
speed.bestTokensPerSecond === null
? "—"
: t("settings.profile.stats.tokensPerSecond", {
value: speed.bestTokensPerSecond.toFixed(1),
})
}
/>
<StatRow
label={t("settings.profile.stats.firstToken")}
value={
speed.averageFirstTokenMs === null
? "—"
: formatMilliseconds(speed.averageFirstTokenMs)
}
/>
</div>
</StatsCard>
);
}
/** Right column: model leaderboard, ranked by tokens exchanged. */
export function TopModelsCard({ stats }: { stats: ProfileStats }) {
const t = useT();
const models = stats.models;
const peak = models.reduce((max, model) => Math.max(max, model.tokens), 0);
return (
<StatsCard
title={t("settings.profile.stats.topModelsTitle")}
description={t("settings.profile.stats.topModelsDescription")}
>
{models.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
{t("settings.profile.stats.noModels")}
</p>
) : (
<ol className="flex flex-col gap-3">
{models.map((model, index) => (
<li key={model.id} className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3">
<span className="flex min-w-0 items-baseline gap-2">
<span className="w-4 shrink-0 text-ui-11 tabular-nums text-muted-foreground">
{index + 1}
</span>
<span
className="min-w-0 truncate text-sm text-foreground"
title={model.id}
>
{model.label}
</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t("settings.profile.stats.modelSummary", {
tokens: formatCompactNumber(model.tokens),
messages: formatFullNumber(model.messages),
})}
</span>
</div>
<StatMeter progress={peak > 0 ? model.tokens / peak : 0} />
</li>
))}
</ol>
)}
</StatsCard>
);
}

View file

@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { useT } from "@/i18n";
import { useProfileStats } from "../../hooks/use-profile-stats";
import { ActivityInsightsCard, TopModelsCard } from "./insights-card";
import { StatsCard } from "./stat-primitives";
import { StatsHighlights } from "./stats-highlights";
import { StatsSkeleton } from "./stats-skeleton";
import { TokenActivityCard } from "./token-activity-card";
import { TrainingHighlightsCard } from "./training-card";
/**
* Everything below the personalization form on the Profile tab: headline
* numbers, activity grid, insights and training.
*
* All of it comes from `/api/profile/stats`, which reads local history only.
*
* Loaded lazily by `profile-stats-panel.tsx` so none of it reaches the main
* bundle, since the Profile tab is the only place it renders.
*/
export function ProfileStatsContent() {
const t = useT();
const { stats, loading, error, reload } = useProfileStats();
if (loading && stats === null) {
return <StatsSkeleton />;
}
if (error !== null && stats === null) {
return (
<StatsCard title={t("settings.profile.stats.title")}>
<div className="flex flex-col items-center gap-3 py-4 text-center">
<p className="text-xs text-muted-foreground">{error}</p>
<Button type="button" size="sm" variant="outline" onClick={reload}>
{t("settings.profile.stats.retry")}
</Button>
</div>
</StatsCard>
);
}
if (stats === null) return null;
const hasChats = stats.totals.messages > 0;
const hasTraining = stats.training.runs > 0;
return (
<div className="flex w-full flex-col gap-4">
<header className="flex flex-col gap-0.5">
<h2
data-settings-label={t("settings.profile.stats.title")}
className="text-base font-semibold font-heading text-foreground"
>
{t("settings.profile.stats.title")}
</h2>
<p className="text-xs text-muted-foreground">
{t("settings.profile.stats.subtitle")}
</p>
</header>
<StatsHighlights stats={stats} />
{hasChats ? (
<>
<TokenActivityCard daily={stats.daily} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ActivityInsightsCard stats={stats} />
<TopModelsCard stats={stats} />
</div>
</>
) : (
<StatsCard>
<p className="py-6 text-center text-xs text-muted-foreground">
{t("settings.profile.stats.emptyChats")}
</p>
</StatsCard>
)}
{hasTraining ? <TrainingHighlightsCard stats={stats} /> : null}
<p className="px-1 pb-2 text-ui-11 text-muted-foreground">
{t("settings.profile.stats.privacyNote")}
</p>
</div>
);
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Suspense, lazy } from "react";
import { StatsSkeleton } from "./stats-skeleton";
// The stats content pulls in recharts for the rhythm charts. Settings live in
// the main bundle, so importing it eagerly would move ~300 KB of charting off
// its own lazy chunk and onto every cold app load. Split it here instead: the
// chunk is fetched only when someone actually opens Settings -> Profile.
const ProfileStatsContent = lazy(() =>
import("./profile-stats-content").then((module) => ({
default: module.ProfileStatsContent,
})),
);
export function ProfileStatsPanel() {
return (
<Suspense fallback={<StatsSkeleton />}>
<ProfileStatsContent />
</Suspense>
);
}

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
/** Bordered surface every stats block sits on, matching the profile card. */
export function StatsCard({
title,
description,
action,
children,
className,
}: {
title?: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
{...(title ? { "data-settings-label": title } : {})}
className={cn(
"flex w-full flex-col gap-4 rounded-2xl border border-border bg-background dark:border-transparent dark:bg-white/[0.06] px-5 py-5",
className,
)}
>
{title ? (
<header className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="text-sm font-semibold font-heading text-foreground">
{title}
</h3>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</header>
) : null}
{children}
</section>
);
}
/** Big number + caption, used across the highlight and training rows. */
export function StatTile({
value,
label,
hint,
className,
}: {
value: string;
label: string;
hint?: string;
className?: string;
}) {
return (
<div
data-settings-label={label}
className={cn(
"flex min-w-0 flex-col items-center gap-0.5 px-2 py-1 text-center",
className,
)}
{...(hint ? { title: hint } : {})}
>
<span className="text-xl font-semibold font-heading tabular-nums text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
}
/** Label left, value right: the "Activity insights" rows. */
export function StatRow({
label,
value,
emphasis,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<div
data-settings-label={label}
className="flex items-center justify-between gap-4 py-1.5"
>
<span className="min-w-0 truncate text-sm text-muted-foreground">
{label}
</span>
<span
className={cn(
"shrink-0 text-sm tabular-nums",
emphasis ? "font-semibold text-foreground" : "text-foreground",
)}
>
{value}
</span>
</div>
);
}
/** Thin progress track (level bar, achievement progress, model share). */
export function StatMeter({
progress,
className,
tone = "primary",
}: {
progress: number;
className?: string;
tone?: "primary" | "muted";
}) {
const clamped = Math.min(
1,
Math.max(0, Number.isFinite(progress) ? progress : 0),
);
return (
<div
className={cn(
"h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15",
className,
)}
>
<div
className={cn(
"h-full rounded-full transition-[width] duration-500",
tone === "primary" ? "bg-primary" : "bg-muted-foreground/50",
)}
style={{ width: `${clamped * 100}%` }}
/>
</div>
);
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useT } from "@/i18n";
import type { ProfileStats } from "../../api/profile-stats";
import {
formatCompactNumber,
formatDuration,
formatFullNumber,
} from "../../utils/stats-format";
import { StatTile } from "./stat-primitives";
/** The five headline numbers, mirroring the app's top-of-profile summary. */
export function StatsHighlights({ stats }: { stats: ProfileStats }) {
const t = useT();
const { totals, streak, peakDay, longestChat } = stats;
const days = (count: number) =>
count === 1
? t("settings.profile.stats.dayCountOne")
: t("settings.profile.stats.dayCount", { count });
return (
<div className="grid grid-cols-2 gap-y-5 rounded-2xl border border-border bg-background dark:border-transparent dark:bg-white/[0.06] px-4 py-5 sm:grid-cols-3 lg:grid-cols-5">
<StatTile
value={formatCompactNumber(totals.totalTokens)}
label={t("settings.profile.stats.lifetimeTokens")}
hint={formatFullNumber(totals.totalTokens)}
/>
<StatTile
value={peakDay ? formatCompactNumber(peakDay.tokens) : "—"}
label={t("settings.profile.stats.peakTokens")}
{...(peakDay ? { hint: peakDay.date } : {})}
/>
<StatTile
value={longestChat ? formatDuration(longestChat.seconds) : "—"}
label={t("settings.profile.stats.longestChat")}
{...(longestChat?.title ? { hint: longestChat.title } : {})}
/>
<StatTile
value={days(streak.current)}
label={t("settings.profile.stats.currentStreak")}
/>
<StatTile
value={days(streak.longest)}
label={t("settings.profile.stats.longestStreak")}
/>
</div>
);
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Skeleton } from "@/components/ui/skeleton";
/**
* Placeholder for the stats panel.
*
* Its own module so the lazy wrapper can render it as a Suspense fallback
* without pulling the chart-bearing content chunk into the main bundle.
*/
export function StatsSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-20 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-56 w-full rounded-2xl" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Skeleton className="h-72 w-full rounded-2xl" />
<Skeleton className="h-72 w-full rounded-2xl" />
</div>
</div>
);
}

View file

@ -0,0 +1,371 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useLocale, useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ProfileStatsDay } from "../../api/profile-stats";
import {
type ActivityMode,
formatCompactNumber,
formatFullNumber,
heatLevel,
parseDayKey,
seriesForMode,
windowBaseline,
} from "../../utils/stats-format";
import { StatsCard } from "./stat-primitives";
const DAYS_PER_WEEK = 7;
const CELL_SIZE = 11;
const CELL_GAP = 3;
const COLUMN_WIDTH = CELL_SIZE + CELL_GAP;
const MIN_COLUMNS = 8;
const HEAT_OPACITY = [0, 0.4, 0.62, 0.8, 1] as const;
// Weekly and cumulative are on/off, so they use one flat shade.
const SOLID_LEVEL = 4;
const MODES: ActivityMode[] = ["daily", "weekly", "cumulative"];
type Cell = {
key: string;
day: ProfileStatsDay | null;
value: number;
};
/**
* Trim the series to the most recent `columns` weeks, ending on a partial
* current week. The grid never scrolls, so older days fall off the left.
*/
function buildColumns(
daily: ProfileStatsDay[],
values: number[],
columns: number,
mode: ActivityMode,
): Cell[][] {
if (daily.length === 0 || columns <= 0) return [];
const lastDay = daily.at(-1);
if (!lastDay) return [];
// Days after today in the final (partial) week.
const trailing =
DAYS_PER_WEEK -
1 -
((parseDayKey(lastDay.date).getDay() + 6) % DAYS_PER_WEEK);
const capacity = columns * DAYS_PER_WEEK - trailing;
const start = Math.max(0, daily.length - capacity);
const visible = daily.slice(start);
// Cumulative is a running total over what the grid shows, so a narrow card
// that drops older weeks has to rebase off the last hidden day.
const baseline = windowBaseline(values, start, mode);
const cells: Cell[] = [];
// Pad so every column is a Monday-started week.
const firstVisible = visible[0];
if (!firstVisible) return [];
const leading = (parseDayKey(firstVisible.date).getDay() + 6) % DAYS_PER_WEEK;
for (let index = 0; index < leading; index += 1) {
cells.push({ key: `pad-${index}`, day: null, value: 0 });
}
for (const [index, day] of visible.entries()) {
cells.push({
key: day.date,
day,
value: (values[start + index] ?? 0) - baseline,
});
}
const grid: Cell[][] = [];
for (let index = 0; index < cells.length; index += DAYS_PER_WEEK) {
grid.push(cells.slice(index, index + DAYS_PER_WEEK));
}
return grid;
}
/** Month captions under the grid, one per column where the month turns over. */
function buildMonthLabels(grid: Cell[][], locale: string) {
const formatter = new Intl.DateTimeFormat(locale, { month: "short" });
const labels: Array<{ key: string; column: number; text: string }> = [];
let lastMonth = -1;
for (const [columnIndex, column] of grid.entries()) {
const firstDay = column.find((cell) => cell.day !== null)?.day;
if (!firstDay) continue;
const date = parseDayKey(firstDay.date);
if (date.getMonth() === lastMonth) continue;
lastMonth = date.getMonth();
// Skip a label that would collide with the previous one, or run off the end.
const previous = labels.at(-1);
if (previous && columnIndex - previous.column < 3) continue;
if (columnIndex > grid.length - 3) continue;
labels.push({
key: firstDay.date,
column: columnIndex,
text: formatter.format(date),
});
}
return labels;
}
/**
* Per-column totals for the bar modes. Shading every day of an active week
* instead would fill the grid solid and hide the shape.
*/
function columnSummary(column: Cell[]) {
let value = 0;
let tokens = 0;
let firstDay: string | null = null;
for (const cell of column) {
if (!cell.day) continue;
value = Math.max(value, cell.value);
tokens += cell.day.tokens;
firstDay ??= cell.day.date;
}
return { value, tokens, firstDay };
}
/** Bar height in cells, at least one for any activity. */
function barHeight(value: number, peakValue: number): number {
if (value <= 0 || peakValue <= 0) return 0;
return Math.max(1, Math.round((value / peakValue) * DAYS_PER_WEEK));
}
/** How many week columns fit the card's current width. */
function useVisibleColumns(maxColumns: number) {
const ref = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(maxColumns);
useEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => {
const width = element.clientWidth;
if (width <= 0) return;
// The final column carries no trailing gap.
const fits = Math.floor((width + CELL_GAP) / COLUMN_WIDTH);
setColumns(Math.max(MIN_COLUMNS, Math.min(maxColumns, fits)));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [maxColumns]);
return { ref, columns };
}
const CELL_CLASS = "size-[11px] rounded-[3px]";
function Block({
title,
tone,
}: { title: string; tone: 0 | 1 | 2 | 3 | 4 | -1 }) {
return (
<div
title={title}
className={cn(
CELL_CLASS,
tone === -1
? "bg-transparent"
: tone === 0
? "bg-muted-foreground/12"
: "bg-primary",
)}
style={
tone > 0 ? { opacity: HEAT_OPACITY[tone as 1 | 2 | 3 | 4] } : undefined
}
/>
);
}
/** Daily: each day shaded by its own volume. */
function DayColumn({
column,
peak,
dateFormatter,
}: {
column: Cell[];
peak: number;
dateFormatter: Intl.DateTimeFormat;
}) {
const t = useT();
return (
<div className="flex flex-col gap-[3px]">
{column.map((cell) => {
if (!cell.day) {
return <Block key={cell.key} title="" tone={-1} />;
}
return (
<Block
key={cell.key}
tone={heatLevel(cell.value, peak)}
title={t("settings.profile.stats.cellTooltip", {
tokens: formatFullNumber(cell.day.tokens),
messages: formatFullNumber(cell.day.messages),
date: dateFormatter.format(parseDayKey(cell.day.date)),
})}
/>
);
})}
</div>
);
}
/** Weekly and cumulative: one flat bar per column, anchored to the bottom. */
function BarColumn({
column,
peak,
dateFormatter,
}: {
column: Cell[];
peak: number;
dateFormatter: Intl.DateTimeFormat;
}) {
const t = useT();
const summary = columnSummary(column);
const height = barHeight(summary.value, peak);
// The bar is scaled by summary.value, which in cumulative mode is the
// running total. The tooltip says "week of", so it reports that week.
const title = summary.firstDay
? t("settings.profile.stats.weekTooltip", {
date: dateFormatter.format(parseDayKey(summary.firstDay)),
tokens: formatFullNumber(summary.tokens),
})
: "";
return (
<div className="flex flex-col gap-[3px]">
{Array.from({ length: DAYS_PER_WEEK }, (_, row) => (
<Block
key={column[row]?.key ?? `slot-${row}`}
title={title}
tone={row >= DAYS_PER_WEEK - height ? SOLID_LEVEL : 0}
/>
))}
</div>
);
}
export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) {
const t = useT();
const [mode, setMode] = useState<ActivityMode>("daily");
const maxColumns = Math.ceil(daily.length / DAYS_PER_WEEK) + 1;
const { ref, columns } = useVisibleColumns(maxColumns);
const shaded = mode === "daily";
const values = useMemo(() => seriesForMode(daily, mode), [daily, mode]);
const grid = useMemo(
() => buildColumns(daily, values, columns, mode),
[daily, values, columns, mode],
);
// The app language, not the browser's: those differ whenever the user picks
// a language in Settings, and it has to be a dependency so switching while
// the panel is open rebuilds the formatters.
const locale = useLocale();
const monthLabels = useMemo(
() => buildMonthLabels(grid, locale),
[grid, locale],
);
// Daily scales against the busiest day, the bar modes against the busiest
// column, so a full-height bar always means the peak week.
const peak = useMemo(
() =>
shaded
? grid.reduce(
(max, column) =>
column.reduce(
(best, cell) => Math.max(best, cell.day?.tokens ?? 0),
max,
),
0,
)
: grid.reduce(
(max, column) => Math.max(max, columnSummary(column).value),
0,
),
[grid, shaded],
);
const visibleTotal = useMemo(
() =>
grid.reduce(
(sum, column) =>
column.reduce((total, cell) => total + (cell.day?.tokens ?? 0), sum),
0,
),
[grid],
);
const dateFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
}),
[locale],
);
return (
<StatsCard
title={t("settings.profile.stats.activityTitle")}
description={t("settings.profile.stats.activityDescription", {
total: formatCompactNumber(visibleTotal),
weeks: grid.length,
})}
action={
<div className="hub-tab-toggle inline-flex h-8 w-fit items-center rounded-full">
{MODES.map((option) => (
<button
key={option}
type="button"
onClick={() => setMode(option)}
aria-pressed={mode === option}
className={cn(
"inline-flex h-8 items-center rounded-full px-3 text-ui-13 font-medium transition-colors",
mode === option
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`settings.profile.stats.mode.${option}`)}
</button>
))}
</div>
}
>
{/* Measured, never scrolled: the grid is trimmed to fit instead. */}
<div ref={ref} className="w-full overflow-hidden">
<div className="flex gap-[3px]">
{grid.map((column) =>
shaded ? (
<DayColumn
key={column[0]?.key ?? "column"}
column={column}
peak={peak}
dateFormatter={dateFormatter}
/>
) : (
<BarColumn
key={column[0]?.key ?? "column"}
column={column}
peak={peak}
dateFormatter={dateFormatter}
/>
),
)}
</div>
<div className="relative mt-2 h-4">
{monthLabels.map((label) => (
<span
key={label.key}
className="absolute top-0 text-ui-11 text-muted-foreground"
style={{ left: `${label.column * COLUMN_WIDTH}px` }}
>
{label.text}
</span>
))}
</div>
</div>
</StatsCard>
);
}

View file

@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import type { ProfileStats } from "../../api/profile-stats";
import {
formatCompactNumber,
formatDuration,
formatFullNumber,
} from "../../utils/stats-format";
import { StatTile, StatsCard } from "./stat-primitives";
const STATUS_TONE: Record<string, string> = {
completed: "text-primary",
running: "text-foreground",
error: "text-destructive",
stopped: "text-muted-foreground",
};
/** Training-side counterpart to the chat stats: runs, steps, GPU time, loss. */
export function TrainingHighlightsCard({ stats }: { stats: ProfileStats }) {
const t = useT();
const { training } = stats;
return (
<StatsCard
title={t("settings.profile.stats.trainingTitle")}
description={t("settings.profile.stats.trainingDescription")}
>
<div className="grid grid-cols-2 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
<StatTile
value={formatFullNumber(training.runs)}
label={t("settings.profile.stats.trainingRuns")}
/>
<StatTile
value={formatFullNumber(training.completed)}
label={t("settings.profile.stats.trainingCompleted")}
/>
<StatTile
value={formatCompactNumber(training.steps)}
label={t("settings.profile.stats.trainingSteps")}
/>
<StatTile
value={formatCompactNumber(training.tokens)}
label={t("settings.profile.stats.trainingTokens")}
/>
<StatTile
value={formatDuration(training.seconds)}
label={t("settings.profile.stats.trainingTime")}
/>
<StatTile
value={
training.bestLoss === null ? "—" : training.bestLoss.toFixed(3)
}
label={t("settings.profile.stats.bestLoss")}
/>
</div>
{training.recent.length > 0 ? (
<ul className="flex flex-col divide-y divide-border/60 border-t border-border/60 pt-1">
{training.recent.map((run) => (
<li
key={run.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="flex min-w-0 flex-col gap-0.5">
{/* A renamed run leads with the name the user chose, so the
model moves down beside the dataset to stay visible. */}
<span
className="min-w-0 truncate text-sm text-foreground"
title={run.name}
>
{run.name}
</span>
<span className="min-w-0 truncate text-ui-11 text-muted-foreground">
{run.name === run.modelLabel
? run.datasetLabel
: `${run.modelLabel} · ${run.datasetLabel}`}
</span>
</div>
<div className="flex shrink-0 items-center gap-4 text-xs tabular-nums">
<span className="text-muted-foreground">
{t("settings.profile.stats.runSteps", {
steps: formatFullNumber(run.steps),
})}
</span>
<span className="text-muted-foreground">
{run.finalLoss === null
? "—"
: t("settings.profile.stats.runLoss", {
loss: run.finalLoss.toFixed(3),
})}
</span>
<span
className={cn(
"w-16 text-right",
STATUS_TONE[run.status] ?? "text-muted-foreground",
)}
>
{run.status}
</span>
</div>
</li>
))}
</ul>
) : null}
</StatsCard>
);
}

View file

@ -30,14 +30,27 @@ const SHAPE: Record<AvatarShape, string> = {
rounded: "rounded-[22%]",
};
export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) {
export function UserAvatar({
name,
imageUrl,
size,
className,
shape,
}: UserAvatarProps) {
const label = initialsFromName(name);
const storedShape = useUserProfileStore((s) => s.avatarShape);
const shapeClass = SHAPE[shape ?? storedShape];
if (imageUrl) {
return (
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
<span
className={cn(
"relative inline-flex shrink-0 overflow-hidden bg-transparent",
shapeClass,
SIZE[size],
className,
)}
>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);

View file

@ -238,7 +238,8 @@ export function usePersonalizationSync(enabled: boolean): void {
// default so the push detects the diff) rather than treating the
// default as an explicit remote choice. A record that actually stored
// the field reports <field>Saved=true and still wins.
const localGreeting = useUserProfileStore.getState().showGreetingSloth;
const localGreeting =
useUserProfileStore.getState().showGreetingSloth;
const remoteGreeting = remote.profile.showGreetingSloth !== false;
const keepLocalGreeting =
remote.greetingSlothSaved === false && localGreeting === false;
@ -248,7 +249,9 @@ export function usePersonalizationSync(enabled: boolean): void {
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
avatarShape:
remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
showGreetingSloth: keepLocalGreeting
? localGreeting
: remoteGreeting,
};
const nextTheme = remote.appearance.theme;
const localPalette = latestPaletteRef.current;

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { type ProfileStats, loadProfileStats } from "../api/profile-stats";
type ProfileStatsState = {
stats: ProfileStats | null;
loading: boolean;
error: string | null;
reload: () => void;
};
/** Load the profile stats on mount, with a manual refresh. */
export function useProfileStats(): ProfileStatsState {
const [stats, setStats] = useState<ProfileStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// A refresh aborts the in-flight request so a slow first load cannot land
// after (and overwrite) the newer one.
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const next = await loadProfileStats(controller.signal);
if (controller.signal.aborted) return;
setStats(next);
setError(null);
} catch (cause: unknown) {
if (controller.signal.aborted) return;
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, []);
useEffect(() => {
void load();
return () => abortRef.current?.abort();
}, [load]);
const reload = useCallback(() => {
void load();
}, [load]);
return { stats, loading, error, reload };
}

View file

@ -2,6 +2,8 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ProfilePersonalizationPanel } from "./components/profile-personalization-panel";
export { ProfileStatsPanel } from "./components/stats/profile-stats-panel";
export { UserAvatar } from "./components/user-avatar";
export { useEffectiveProfile } from "./hooks/use-effective-profile";
export { usePersonalizationSync } from "./hooks/use-personalization-sync";
export { useUserProfileStore } from "./stores/user-profile-store";

View file

@ -28,7 +28,11 @@ function loadImage(file: File): Promise<HTMLImageElement> {
});
}
function canvasHasTransparency(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
function canvasHasTransparency(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
): boolean {
const { data } = ctx.getImageData(0, 0, width, height);
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) return true;
@ -67,7 +71,12 @@ function drawScaled(
w: number,
h: number,
maxEdge: number,
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } {
): {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
cw: number;
ch: number;
} {
const scale = Math.min(1, maxEdge / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
@ -96,16 +105,28 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
// would paint a background behind a transparent image.
for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) {
const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge);
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
const webpDataUrl = encodeCanvasWithinLimit(
canvas,
"image/webp",
WEBP_QUALITY_START,
);
if (webpDataUrl) return webpDataUrl;
const pngDataUrl = encodePngWithinLimit(canvas);
if (pngDataUrl) return pngDataUrl;
}
throw new Error("Image is still too large after compression. Try a smaller file.");
throw new Error(
"Image is still too large after compression. Try a smaller file.",
);
}
const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START);
const jpegDataUrl = encodeCanvasWithinLimit(
base.canvas,
"image/jpeg",
JPEG_QUALITY_START,
);
if (jpegDataUrl) return jpegDataUrl;
throw new Error("Image is still too large after compression. Try a smaller file.");
throw new Error(
"Image is still too large after compression. Try a smaller file.",
);
}

View file

@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Formatting helpers for the profile stats panel.
*
* Kept free of React so the numbers can be unit-tested directly.
*/
const TRAILING_ZERO_DECIMAL = /\.0$/;
/** Compact form used on every stat tile: 12.3K, 4.5M, 19.8B. */
export function formatCompactNumber(value: number): string {
if (!Number.isFinite(value)) return "0";
const abs = Math.abs(value);
if (abs < 1000) return String(Math.round(value));
const units: Array<{ limit: number; suffix: string }> = [
{ limit: 1e12, suffix: "T" },
{ limit: 1e9, suffix: "B" },
{ limit: 1e6, suffix: "M" },
{ limit: 1e3, suffix: "K" },
];
for (const [index, { limit, suffix }] of units.entries()) {
if (abs < limit) continue;
const scaled = value / limit;
// One decimal below 100 keeps "1.9B" readable; above it the decimal is noise.
const rounded =
Math.abs(scaled) >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1));
// Rounding can push a value over the next boundary, and "1000K" is not
// compact. Step up a unit rather than print four digits.
const next = units[index - 1];
if (next && Math.abs(rounded) >= 1000) {
return `${(value / next.limit).toFixed(1).replace(TRAILING_ZERO_DECIMAL, "")}${next.suffix}`;
}
const text =
Math.abs(scaled) >= 100 ? rounded.toString() : scaled.toFixed(1);
return `${text.replace(TRAILING_ZERO_DECIMAL, "")}${suffix}`;
}
return String(Math.round(value));
}
export function formatFullNumber(value: number): string {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString();
}
/** Compact duration for chat and training time: 4h 8m, 12m 30s, 45s. */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "0m";
const total = Math.round(seconds);
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`;
return `${secs}s`;
}
export function formatMilliseconds(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/**
* Bucket a day's tokens into one of five heatmap intensities (0 = empty).
* Thresholds are relative to the busiest day so any usage scale looks alive.
*/
export function heatLevel(tokens: number, peak: number): 0 | 1 | 2 | 3 | 4 {
if (tokens <= 0) return 0;
if (peak <= 0) return 1;
const ratio = tokens / peak;
if (ratio > 0.6) return 4;
if (ratio > 0.3) return 3;
if (ratio > 0.1) return 2;
return 1;
}
/** Local YYYY-MM-DD, matching the backend's day keys (which use local time). */
export function toLocalDayKey(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Parse a backend day key as a local date (not UTC, which would shift a day). */
export function parseDayKey(key: string): Date {
const [year, month, day] = key.split("-").map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
export type ActivityMode = "daily" | "weekly" | "cumulative";
/**
* Recast the dense daily series for the selected mode. Weekly sums each
* calendar week onto its days so the grid shows week-level intensity;
* cumulative is the running total across the displayed window, not lifetime,
* since the backend caps the series and seeding it with everything older would
* flatten every bar against a baseline the grid cannot show.
*/
/**
* What to subtract from a cumulative series once the grid drops older days.
* Without it the first visible bar opens at the hidden total and the whole
* window flattens against a baseline the user cannot see.
*/
export function windowBaseline(
values: number[],
start: number,
mode: ActivityMode,
): number {
if (mode !== "cumulative" || start <= 0) return 0;
return values[start - 1] ?? 0;
}
export function seriesForMode(
daily: Array<{ date: string; tokens: number }>,
mode: ActivityMode,
): number[] {
if (mode === "daily") return daily.map((day) => day.tokens);
if (mode === "cumulative") {
let running = 0;
return daily.map((day) => {
running += day.tokens;
return running;
});
}
// Weekly: every day carries the total of the Monday-started week it sits in.
const weekTotals: number[] = [];
const weekOfDay: number[] = [];
let week = -1;
for (const [index, day] of daily.entries()) {
const isMonday = parseDayKey(day.date).getDay() === 1;
if (index === 0 || isMonday) {
week += 1;
weekTotals[week] = 0;
}
weekTotals[week] = (weekTotals[week] ?? 0) + day.tokens;
weekOfDay[index] = week;
}
return weekOfDay.map((weekIndex) => weekTotals[weekIndex] ?? 0);
}

View file

@ -63,7 +63,12 @@ interface TabDef {
const TABS: TabDef[] = [
{ id: "general", labelKey: "settings.tabs.general", icon: Settings02Icon },
{ id: "profile", labelKey: "settings.tabs.profile", icon: UserIcon },
{
id: "profile",
labelKey: "settings.tabs.profile",
icon: UserIcon,
badgeKey: "common.new",
},
{
id: "appearance",
labelKey: "settings.tabs.appearance",

View file

@ -37,8 +37,8 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
"settings.profile.description",
"settings.profile.displayName",
"settings.profile.nickname",
"settings.profile.avatarShape",
"settings.profile.greetingSloth",
// avatarShape lives inside the avatar edit popover, so it has no
// always-rendered label for search to scroll to.
],
appearance: [
"settings.appearance.theme.label",
@ -81,6 +81,7 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
chat: [
"settings.general.chatDefaults",
"settings.general.autoTitleNewChats",
"settings.profile.greetingSloth",
"settings.chat.artifacts.title",
"settings.chat.artifacts.collapseHtmlBlocks",
"settings.chat.artifacts.allowNetworkAccess",

View file

@ -8,6 +8,7 @@ import {
useChatRuntimeStore,
usePlusMenuPrefsStore,
} from "@/features/chat";
import { useUserProfileStore } from "@/features/profile";
import { useT } from "@/i18n";
import {
Bookmark02Icon,
@ -126,6 +127,10 @@ export function ChatTab() {
const togglePlusPin = usePlusMenuPrefsStore((state) => state.togglePin);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const setShowGreetingSloth = useUserProfileStore(
(s) => s.setShowGreetingSloth,
);
const showCanvasMenuItem = useChatRuntimeStore(
(state) => state.showCanvasMenuItem,
);
@ -276,6 +281,16 @@ export function ChatTab() {
>
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
</SettingsRow>
<SettingsRow
label={t("settings.profile.greetingSloth")}
description={t("settings.profile.greetingSlothDescription")}
>
<Switch
id="profile-greeting-sloth"
checked={showGreetingSloth}
onCheckedChange={setShowGreetingSloth}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.chat.artifacts.title")}>

View file

@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { ProfilePersonalizationPanel } from "@/features/profile";
import {
ProfilePersonalizationPanel,
ProfileStatsPanel,
} from "@/features/profile";
import { useT } from "@/i18n";
export function ProfileTab() {
@ -25,6 +28,7 @@ export function ProfileTab() {
</header>
<ProfilePersonalizationPanel />
<ProfileStatsPanel />
</div>
);
}

View file

@ -383,16 +383,19 @@ export const en = {
title: "Profile",
description: "How your profile appears in Unsloth.",
changePicture: "Change profile picture",
uploadPhoto: "Upload photo",
removePhoto: "Remove",
pictureOptions: "Profile picture options",
displayName: "Display name",
nickname: "What should Unsloth call you?",
nicknamePlaceholder: "Nickname",
nicknameSaved: "Preferred name saved",
avatarShape: "Profile picture shape",
avatarShape: "Avatar shape",
avatarShapeCircle: "Circle",
avatarShapeRounded: "Rounded",
greetingSloth: "Sloth in greeting",
greetingSlothDescription: "Show the sloth in the chat greeting.",
chooseSloth: "Or pick a sloth profile picture",
chooseSloth: "Or pick a sloth",
noPicture: "No profile picture",
noneLabel: "None",
nameSaved: "Profile name saved",
@ -405,6 +408,64 @@ export const en = {
"Photo updated for this session, but may not persist after reload.",
photoUpdateErrorTitle: "Could not update profile photo",
imageUseError: "Could not use this image.",
stats: {
title: "Your stats",
subtitle:
"Everything below is counted from your own history. Nothing is collected or sent to Unsloth.",
retry: "Try again",
privacyNote:
"Stats are computed from the chat and training history held by your Unsloth install. Nothing is collected, and nothing is sent to Unsloth or any third party.",
emptyChats:
"No chats yet. Start a conversation and your stats will fill in here.",
lifetimeTokens: "Lifetime tokens",
peakTokens: "Peak day",
longestChat: "Longest chat",
currentStreak: "Current streak",
longestStreak: "Longest streak",
dayCount: "{count} days",
dayCountOne: "1 day",
activityTitle: "Token activity",
activityDescription: "{total} tokens over the last {weeks} weeks",
mode: {
daily: "Daily",
weekly: "Weekly",
cumulative: "Cumulative",
},
cellTooltip: "{date} · {tokens} tokens, {messages} messages",
weekTooltip: "Week of {date} · {tokens} tokens",
less: "Less",
more: "More",
insightsTitle: "Activity insights",
totalChats: "Total chats",
totalMessages: "Total messages",
tokensIn: "Tokens sent",
tokensOut: "Tokens generated",
cachedTokens: "Cached tokens",
cachedValue: "{tokens} ({percent}% of input)",
avgTokensPerChat: "Average tokens per chat",
timeInChat: "Time in chat",
activeDays: "Active days",
toolCalls: "Tool calls",
attachments: "Files attached",
avgSpeed: "Average speed",
bestSpeed: "Fastest response",
firstToken: "Average time to first token",
tokensPerSecond: "{value} tok/s",
topModelsTitle: "Most used models",
topModelsDescription: "Ranked by tokens exchanged",
modelSummary: "{tokens} · {messages} msgs",
noModels: "No model usage recorded yet.",
trainingTitle: "Training",
trainingDescription: "Fine-tuning runs from this workspace",
trainingRuns: "Runs",
trainingCompleted: "Completed",
trainingSteps: "Steps",
trainingTokens: "Tokens trained",
trainingTime: "Training time",
bestLoss: "Best loss",
runSteps: "{steps} steps",
runLoss: "loss {loss}",
},
},
appearance: {
title: "Appearance",

View file

@ -0,0 +1,113 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import {
formatCompactNumber,
formatDuration,
formatMilliseconds,
heatLevel,
parseDayKey,
seriesForMode,
windowBaseline,
} from "../src/features/profile/utils/stats-format.ts";
test("compact numbers match the tile format", () => {
assert.equal(formatCompactNumber(0), "0");
assert.equal(formatCompactNumber(999), "999");
assert.equal(formatCompactNumber(1000), "1K");
assert.equal(formatCompactNumber(12_340), "12.3K");
assert.equal(formatCompactNumber(1_900_000_000), "1.9B");
assert.equal(formatCompactNumber(19_800_000_000), "19.8B");
// Past 100 of a unit the decimal is noise.
assert.equal(formatCompactNumber(123_400), "123K");
assert.equal(formatCompactNumber(Number.NaN), "0");
});
test("rounding up a unit steps to the next suffix", () => {
// Rounding 999.5K to "1000K" is four digits, which is not compact.
assert.equal(formatCompactNumber(999_999), "1M");
assert.equal(formatCompactNumber(999_500), "1M");
assert.equal(formatCompactNumber(999_999_999), "1B");
assert.equal(formatCompactNumber(999_999_999_999), "1T");
assert.equal(formatCompactNumber(-999_999), "-1M");
// Just below the rounding boundary the unit is unchanged.
assert.equal(formatCompactNumber(999_499), "999K");
});
test("durations read the way the header does", () => {
assert.equal(formatDuration(0), "0m");
assert.equal(formatDuration(45), "45s");
assert.equal(formatDuration(90), "1m 30s");
assert.equal(formatDuration(14_880), "4h 8m");
assert.equal(formatDuration(180_000), "2d 2h");
assert.equal(formatMilliseconds(420), "420ms");
assert.equal(formatMilliseconds(2500), "2.5s");
assert.equal(formatMilliseconds(0), "—");
});
test("heat levels are relative to the busiest day", () => {
assert.equal(heatLevel(0, 1000), 0);
assert.equal(heatLevel(50, 1000), 1);
assert.equal(heatLevel(200, 1000), 2);
assert.equal(heatLevel(400, 1000), 3);
assert.equal(heatLevel(1000, 1000), 4);
// A single active day with no other history still shows up.
assert.equal(heatLevel(5, 0), 1);
});
test("day keys parse as local dates, not UTC", () => {
const parsed = parseDayKey("2026-03-09");
assert.equal(parsed.getFullYear(), 2026);
assert.equal(parsed.getMonth(), 2);
assert.equal(parsed.getDate(), 9);
});
test("series modes reshape the same daily data", () => {
// 2026-03-02 is a Monday, so this spans exactly two calendar weeks.
const daily = [
{ date: "2026-03-02", tokens: 10 },
{ date: "2026-03-03", tokens: 20 },
{ date: "2026-03-08", tokens: 5 },
{ date: "2026-03-09", tokens: 100 },
];
assert.deepEqual(seriesForMode(daily, "daily"), [10, 20, 5, 100]);
assert.deepEqual(seriesForMode(daily, "cumulative"), [10, 30, 35, 135]);
// First three days are in the week of Mar 2 (35), Mar 9 starts a new week.
assert.deepEqual(seriesForMode(daily, "weekly"), [35, 35, 35, 100]);
assert.deepEqual(seriesForMode([], "weekly"), []);
});
test("a trimmed cumulative window rebases off the last hidden day", () => {
const daily = [
{ date: "2026-01-01", tokens: 1000 },
{ date: "2026-01-02", tokens: 2000 },
{ date: "2026-01-03", tokens: 5 },
{ date: "2026-01-04", tokens: 10 },
];
const values = seriesForMode(daily, "cumulative");
assert.deepEqual(values, [1000, 3000, 3005, 3015]);
// Showing only the last two days: without rebasing, both bars sit at ~3000
// and the 5 vs 10 difference is invisible.
const baseline = windowBaseline(values, 2, "cumulative");
assert.equal(baseline, 3000);
assert.deepEqual(
values.slice(2).map((value) => value - baseline),
[5, 15],
);
});
test("nothing is rebased when the window shows everything", () => {
const values = [1, 3, 6];
assert.equal(windowBaseline(values, 0, "cumulative"), 0);
});
test("only cumulative rebases: daily and weekly are already per-window", () => {
const values = [10, 20, 5];
assert.equal(windowBaseline(values, 2, "daily"), 0);
assert.equal(windowBaseline(values, 2, "weekly"), 0);
});