diff --git a/solstone/apps/activities/maint/000_migrate_activity_icon_to_emoji.py b/solstone/apps/activities/maint/000_migrate_activity_icon_to_emoji.py new file mode 100644 index 000000000..bd27d4156 --- /dev/null +++ b/solstone/apps/activities/maint/000_migrate_activity_icon_to_emoji.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Migrate legacy custom activity glyphs from icon to emoji.""" + +from __future__ import annotations + +import argparse + +from solstone.think.activities import migrate_custom_activity_icons_to_emoji +from solstone.think.utils import setup_cli + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + setup_cli(parser) + parser.add_argument( + "--dry-run", + action="store_true", + help="report records that would change without writing activity config", + ) + args = parser.parse_args() + + result = migrate_custom_activity_icons_to_emoji(dry_run=args.dry_run) + action = "Would migrate" if args.dry_run else "Migrated" + print( + f"{action} {result['records_changed']} custom activity record(s) " + f"across {result['files_changed']} file(s); " + f"scanned {result['files_scanned']} file(s)." + ) + + +if __name__ == "__main__": + main() diff --git a/solstone/apps/activities/routes.py b/solstone/apps/activities/routes.py index 2f2ad8659..dad070fe5 100644 --- a/solstone/apps/activities/routes.py +++ b/solstone/apps/activities/routes.py @@ -12,6 +12,7 @@ from flask import Blueprint, current_app, jsonify, redirect, request, url_for from solstone.convey import state from solstone.convey.date_nav import build_date_nav_index +from solstone.convey.icons import lucide_svg, resolve_icon_svg from solstone.convey.reasons import ( ACTIVITIES_BUSY, ACTIVITY_ALREADY_EXISTS, @@ -59,7 +60,8 @@ activities_bp = Blueprint( url_prefix="/app/activities", ) -_GENERIC_ACTIVITY_ICON = "\U0001f5d3" +_GENERIC_ACTIVITY_EMOJI = "\U0001f5d3" +_GENERIC_ACTIVITY_LUCIDE = "calendar-days" @activities_bp.route("/") @@ -373,9 +375,13 @@ def _enrich_activity_record( activity_def = get_default_activity_by_id(activity_type) name = activity_def.get("name", activity_type) if activity_def else activity_type + emoji = activity_def.get("emoji", "") if activity_def else "" icon = activity_def.get("icon", "") if activity_def else "" - if not icon: - icon = _GENERIC_ACTIVITY_ICON + icon_svg = resolve_icon_svg(icon, "") if icon else None + if not emoji and not icon_svg: + emoji = _GENERIC_ACTIVITY_EMOJI + icon = _GENERIC_ACTIVITY_LUCIDE + icon_svg = lucide_svg(_GENERIC_ACTIVITY_LUCIDE) segments = record.get("segments", []) start_time = end_time = None @@ -428,7 +434,9 @@ def _enrich_activity_record( "id": record["id"], "activity": activity_type, "name": name, + "emoji": emoji, "icon": icon, + "icon_svg": icon_svg, "facet": facet, "description": record.get("description", ""), "level_avg": record.get("level_avg", 0.5), diff --git a/solstone/apps/activities/workspace.html b/solstone/apps/activities/workspace.html index fb8c56fc7..e0816ca73 100644 --- a/solstone/apps/activities/workspace.html +++ b/solstone/apps/activities/workspace.html @@ -45,6 +45,7 @@ border-radius: 6px; display: flex; align-items: flex-start; + gap: 0.3em; padding: 2px 6px; font-size: 0.75em; overflow: hidden; @@ -53,6 +54,27 @@ box-shadow: none; } +.occ-activity-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + line-height: 1; +} + +.occ-activity-icon svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + stroke: currentColor; + fill: none; +} + +.occ-activity-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + h1 { font-size: 1.5em; font-weight: 600; @@ -122,8 +144,19 @@ h1 { } .activity-card .act-icon { + display: inline-flex; + align-items: center; font-size: 1.3em; flex-shrink: 0; + line-height: 1; +} + +.activity-card .act-icon svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + stroke: currentColor; + fill: none; } .activity-card .act-body { @@ -194,10 +227,28 @@ h1 { } .activity-detail-header h2 { + display: flex; + align-items: center; + gap: 0.4em; margin: 0 0 0.25em 0; font-size: 1.3em; } +.activity-detail-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + line-height: 1; +} + +.activity-detail-icon svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + stroke: currentColor; + fill: none; +} + .activity-detail-header .ad-subtitle { color: #666; font-size: 0.9em; @@ -716,14 +767,18 @@ button.activity-detail-back { const top = ((sMin - startHour * 60) / totalMinutes) * 100; const height = Math.max((eMin - sMin) / totalMinutes * 100, 0.4); const color = levelColor(a.level_avg); - const icon = a.icon || '๐Ÿ—“๏ธ'; - html += `${escapeHtml(icon)} ${escapeHtml(a.name)}`; + const iconHtml = activityIconHtml(a); + html += `${escapeHtml(a.name)}`; }); html += ''; div.innerHTML = html; } + function activityIconHtml(activity) { + return activity.icon_svg || escapeHtml(activity.emoji || ''); + } + // โ”€โ”€ Activities cards โ”€โ”€ function renderActivities(list) { @@ -741,9 +796,9 @@ button.activity-detail-back { const lvl = levelLabel(a.level_avg); const timeStr = a.startTime ? fmtTime(a.startTime) : ''; const color = levelColor(a.level_avg); - const icon = a.icon || '๐Ÿ—“๏ธ'; + const iconHtml = activityIconHtml(a); html += `
`; - html += `${escapeHtml(icon)}`; + html += ``; html += `
`; html += `
${escapeHtml(a.name)}
`; if (a.description) html += `
${escapeHtml(a.description)}
`; @@ -783,12 +838,12 @@ button.activity-detail-back { const timeRange = a.startTime ? `${fmtTime(a.startTime)}${a.endTime ? ' โ€“ ' + fmtTime(a.endTime) : ''}` : ''; - const icon = a.icon || '๐Ÿ—“๏ธ'; + const iconHtml = activityIconHtml(a); let html = ''; html += ``; html += `
`; - html += `

${escapeHtml(icon)} ${escapeHtml(a.name)}

`; + html += `

${escapeHtml(a.name)}

`; html += `
${escapeHtml(a.facet)}
`; html += `
`; diff --git a/solstone/apps/settings/routes.py b/solstone/apps/settings/routes.py index cbb42a386..dec89683d 100644 --- a/solstone/apps/settings/routes.py +++ b/solstone/apps/settings/routes.py @@ -26,7 +26,7 @@ from solstone.apps.settings import install_copy, transcribe_resource from solstone.apps.utils import log_app_action from solstone.convey import chat_stream, state from solstone.convey import copy as convey_copy -from solstone.convey.icons import resolve_icon_svg +from solstone.convey.icons import is_lucide_icon, resolve_icon_svg from solstone.convey.reasons import ( ACTIVITY_INVALID, ACTIVITY_NOT_FOUND, @@ -131,13 +131,32 @@ def _public_facet_record(name: str, data: dict[str, object]) -> dict[str, object "color": str(data.get("color") or ""), "emoji": str(data.get("emoji") or ""), "icon": str(data.get("icon") or ""), - "icon_svg": resolve_icon_svg( - data.get("icon"), str(data.get("emoji") or "") - ), + "icon_svg": resolve_icon_svg(data.get("icon"), str(data.get("emoji") or "")), "muted": bool(data.get("muted", False)), } +def _public_activity_record(activity: dict[str, Any]) -> dict[str, Any]: + payload = dict(activity) + icon = payload.get("icon") + payload["icon_svg"] = resolve_icon_svg(icon if isinstance(icon, str) else None, "") + return payload + + +def _public_activity_records(activities: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [_public_activity_record(activity) for activity in activities] + + +def _validate_activity_icon(value: object) -> str | None: + if value is None: + return None + if value == "": + return "" + if not isinstance(value, str) or not is_lucide_icon(value): + raise ValueError("icon must be a Lucide name; send emoji in emoji") + return value + + # API keys that can be configured in the env section # Used for system env checks and allowed env fields validation API_KEY_ENV_VARS = [ @@ -1489,7 +1508,7 @@ def get_default_activities() -> Any: try: from solstone.think.activities import get_default_activities as _get_defaults - return jsonify({"activities": _get_defaults()}) + return jsonify({"activities": _public_activity_records(_get_defaults())}) except Exception: logger.exception("error loading default activities") return _settings_operation_failed() @@ -1518,7 +1537,12 @@ def get_facet_activities(facet_name: str) -> Any: attached = _get_facet_activities(facet_name) defaults = _get_defaults() - return jsonify({"activities": attached, "defaults": defaults}) + return jsonify( + { + "activities": _public_activity_records(attached), + "defaults": _public_activity_records(defaults), + } + ) except Exception: logger.exception("error loading facet activities") @@ -1537,7 +1561,8 @@ def add_facet_activity(facet_name: str) -> Any: name: Display name (required for custom, optional for predefined) description: Activity description (optional) priority: "high", "normal", or "low" (optional, default: "normal") - icon: Emoji icon (optional, for custom activities) + emoji: Glyph fallback (optional, for custom activities) + icon: Lucide icon name (optional, for custom activities) """ try: from solstone.think.activities import ( @@ -1588,6 +1613,11 @@ def add_facet_activity(facet_name: str) -> Any: detail="'name' is required for custom activities", ) + try: + icon = _validate_activity_icon(data.get("icon")) + except ValueError as exc: + return error_response(ACTIVITY_INVALID, detail=str(exc)) + activity = add_activity_to_facet( facet_name, activity_id, @@ -1595,7 +1625,8 @@ def add_facet_activity(facet_name: str) -> Any: description=data.get("description"), instructions=data.get("instructions"), priority=priority, - icon=data.get("icon"), + emoji=data.get("emoji"), + icon=icon, ) log_app_action( @@ -1605,7 +1636,9 @@ def add_facet_activity(facet_name: str) -> Any: params={"activity_id": activity_id}, ) - return jsonify({"success": True, "activity": activity}), 201 + return jsonify( + {"success": True, "activity": _public_activity_record(activity)} + ), 201 except Exception: logger.exception("error adding activity") @@ -1621,7 +1654,8 @@ def update_facet_activity(facet_name: str, activity_id: str) -> Any: instructions: Detection/level instructions for the LLM priority: "high", "normal", or "low" name: New name (only for custom activities) - icon: New icon (only for custom activities) + emoji: New glyph fallback (only for custom activities) + icon: New Lucide icon name (only for custom activities) """ try: from solstone.think.activities import update_activity_in_facet @@ -1644,6 +1678,11 @@ def update_facet_activity(facet_name: str, activity_id: str) -> Any: detail="priority must be 'high', 'normal', or 'low'", ) + try: + icon = _validate_activity_icon(data.get("icon")) + except ValueError as exc: + return error_response(ACTIVITY_INVALID, detail=str(exc)) + activity = update_activity_in_facet( facet_name, activity_id, @@ -1651,7 +1690,8 @@ def update_facet_activity(facet_name: str, activity_id: str) -> Any: instructions=data.get("instructions"), priority=priority, name=data.get("name"), - icon=data.get("icon"), + emoji=data.get("emoji"), + icon=icon, ) if activity is None: @@ -1667,7 +1707,7 @@ def update_facet_activity(facet_name: str, activity_id: str) -> Any: params={"activity_id": activity_id, "updates": data}, ) - return jsonify({"success": True, "activity": activity}) + return jsonify({"success": True, "activity": _public_activity_record(activity)}) except Exception: logger.exception("error updating activity") diff --git a/solstone/apps/settings/tests/test_facet_detail.py b/solstone/apps/settings/tests/test_facet_detail.py index d106e5b13..ea8d99534 100644 --- a/solstone/apps/settings/tests/test_facet_detail.py +++ b/solstone/apps/settings/tests/test_facet_detail.py @@ -9,6 +9,7 @@ from pathlib import Path from solstone.apps.settings import copy as settings_copy from solstone.convey import create_app from solstone.convey.icons import lucide_svg +from solstone.convey.reasons import ACTIVITY_INVALID def _write_facet( @@ -152,6 +153,69 @@ def test_settings_facets_api_returns_icon_override_svg(settings_env): assert facet["icon_svg"] == lucide_svg("brain") +def test_settings_activity_post_accepts_emoji_and_lucide_icon(settings_env): + journal, client = _settings_client(settings_env) + _write_facet(journal, "activity-facet", title="Activity Facet") + + response = client.post( + "/app/settings/api/facet/activity-facet/activities", + json={ + "activity_id": "deep_work", + "name": "Deep work", + "description": "Focused custom work", + "emoji": "๐ŸŽฏ", + "icon": "target", + }, + ) + + assert response.status_code == 201 + activity = response.get_json()["activity"] + assert activity["emoji"] == "๐ŸŽฏ" + assert activity["icon"] == "target" + assert activity["icon_svg"] == lucide_svg("target") + + +def test_settings_activity_post_rejects_emoji_in_icon(settings_env): + journal, client = _settings_client(settings_env) + _write_facet(journal, "activity-facet", title="Activity Facet") + + response = client.post( + "/app/settings/api/facet/activity-facet/activities", + json={ + "activity_id": "bad_icon", + "name": "Bad icon", + "emoji": "๐ŸŽฏ", + "icon": "๐ŸŽฏ", + }, + ) + + assert response.status_code == 400 + assert response.get_json()["reason_code"] == ACTIVITY_INVALID.code + + +def test_settings_activity_put_rejects_emoji_in_icon(settings_env): + journal, client = _settings_client(settings_env) + _write_facet(journal, "activity-facet", title="Activity Facet") + created = client.post( + "/app/settings/api/facet/activity-facet/activities", + json={ + "activity_id": "deep_work", + "name": "Deep work", + "emoji": "๐ŸŽฏ", + "icon": "target", + }, + ) + assert created.status_code == 201 + + response = client.put( + "/app/settings/api/facet/activity-facet/activities/deep_work", + json={"icon": "๐ŸŽฏ"}, + ) + + assert response.status_code == 400 + assert response.get_json()["reason_code"] == ACTIVITY_INVALID.code + + def test_settings_index_has_hidden_guard(): """The SPA swaps views by toggling the `hidden` attribute on #settings-index-view. `.settings-wrap` sets display:flex, which ties the UA diff --git a/solstone/apps/settings/workspace.html b/solstone/apps/settings/workspace.html index d20e1ec70..998f3466a 100644 --- a/solstone/apps/settings/workspace.html +++ b/solstone/apps/settings/workspace.html @@ -1601,11 +1601,21 @@ input:checked + .slider:before { } .activity-icon { + display: inline-flex; + align-items: center; font-size: 1.5em; line-height: 1; flex-shrink: 0; } +.activity-icon svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + stroke: currentColor; + fill: none; +} + .activity-info { flex: 1; min-width: 0; @@ -1714,7 +1724,18 @@ input:checked + .slider:before { } .activity-chip-icon { + display: inline-flex; + align-items: center; font-size: 1.1em; + line-height: 1; +} + +.activity-chip-icon svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + stroke: currentColor; + fill: none; } .activities-custom { @@ -5829,7 +5850,7 @@ function renderActivities(data) { countEl.textContent = `(${attached.length})`; attachedList.innerHTML = attached.map(a => `
- ${a.icon || '๐Ÿ“Œ'} +
${escapeHtml(a.name)}
${escapeHtml(a.description) || 'no description'}
@@ -5861,7 +5882,7 @@ function renderActivities(data) { const isAttached = attachedIds.has(d.id); return ` - ${d.icon || '๐Ÿ“Œ'} + ${escapeHtml(d.name)} `; diff --git a/solstone/think/activities.py b/solstone/think/activities.py index 66edfa55f..c12c59608 100644 --- a/solstone/think/activities.py +++ b/solstone/think/activities.py @@ -27,6 +27,7 @@ from solstone.think.utils import get_journal, segment_parse logger = logging.getLogger(__name__) ANTICIPATION_FUZZY_THRESHOLD = 0.85 +LUCIDE_ICON_NAME_RE = re.compile(r"^[a-z0-9-]+$") # --------------------------------------------------------------------------- # Default Activities @@ -40,7 +41,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "meeting", "name": "Meetings", "description": "Video calls, in-person meetings, and conferences", - "icon": "๐Ÿ“…", + "emoji": "๐Ÿ“…", + "icon": "users", "always_on": True, "instructions": ( "Levels: high=actively speaking/presenting, medium=listening attentively," @@ -52,70 +54,80 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "call", "name": "call", "description": "a call you have planned", - "icon": "๐Ÿ“ž", + "emoji": "๐Ÿ“ž", + "icon": "phone", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "deadline", "name": "deadline", "description": "a deadline you are working toward", - "icon": "โฐ", + "emoji": "โฐ", + "icon": "alarm-clock", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "appointment", "name": "appointment", "description": "an appointment on your calendar", - "icon": "๐Ÿ“Œ", + "emoji": "๐Ÿ“Œ", + "icon": "pin", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "event", "name": "event", "description": "an event you plan to attend", - "icon": "๐ŸŽŸ๏ธ", + "emoji": "๐ŸŽŸ๏ธ", + "icon": "ticket", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "travel", "name": "travel", "description": "travel you have planned", - "icon": "โœˆ๏ธ", + "emoji": "โœˆ๏ธ", + "icon": "plane", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "reminder", "name": "reminder", "description": "a reminder for something upcoming", - "icon": "๐Ÿ””", + "emoji": "๐Ÿ””", + "icon": "bell", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "errand", "name": "errand", "description": "an errand you plan to do", - "icon": "๐Ÿงพ", + "emoji": "๐Ÿงพ", + "icon": "receipt", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "celebration", "name": "celebration", "description": "a celebration on the calendar", - "icon": "๐ŸŽ‰", + "emoji": "๐ŸŽ‰", + "icon": "party-popper", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "doctor_appointment", "name": "doctor appointment", "description": "a medical appointment on your calendar", - "icon": "๐Ÿฉบ", + "emoji": "๐Ÿฉบ", + "icon": "stethoscope", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", }, { "id": "coding", "name": "Coding", "description": "Programming, code review, and debugging", - "icon": "๐Ÿ’ป", + "emoji": "๐Ÿ’ป", + "icon": "code-xml", "instructions": ( "Levels: high=writing or debugging code, medium=reading/reviewing code," " low=IDE or editor open but not focused." @@ -127,7 +139,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "browsing", "name": "Browsing", "description": "Web browsing, research, and reading online", - "icon": "๐ŸŒ", + "emoji": "๐ŸŒ", + "icon": "globe", "instructions": ( "Levels: high=actively navigating/researching, medium=reading a page," " low=browser open but idle." @@ -138,7 +151,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "email", "name": "Email", "description": "Email reading and composition", - "icon": "๐Ÿ“ง", + "emoji": "๐Ÿ“ง", + "icon": "mail", "always_on": True, "instructions": ( "Levels: high=composing or actively reading email," @@ -150,7 +164,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "messaging", "name": "Messaging", "description": "Chat, Slack, Discord, and text messaging", - "icon": "๐Ÿ’ฌ", + "emoji": "๐Ÿ’ฌ", + "icon": "messages-square", "always_on": True, "instructions": ( "Levels: high=active conversation, medium=reading messages," @@ -162,7 +177,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "ai_conversation", "name": "AI Conversation", "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", - "icon": "๐Ÿค–", + "emoji": "๐Ÿค–", + "icon": "bot", "instructions": ( "Levels: high=actively prompting and reading responses," " medium=reviewing AI output or refining prompts," @@ -177,7 +193,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "writing", "name": "Writing", "description": "Documents, notes, and long-form writing", - "icon": "โœ๏ธ", + "emoji": "โœ๏ธ", + "icon": "pencil-line", "instructions": ( "Levels: high=actively composing text, medium=editing/revising," " low=document open but not being edited." @@ -188,7 +205,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "reading", "name": "Reading", "description": "Books, PDFs, articles, highlights, and documentation", - "icon": "๐Ÿ“–", + "emoji": "๐Ÿ“–", + "icon": "book-open", "instructions": ( "Levels: high=focused reading, medium=skimming content," " low=document open but attention elsewhere." @@ -201,7 +219,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "video", "name": "Video", "description": "Watching videos and streaming content", - "icon": "๐ŸŽฌ", + "emoji": "๐ŸŽฌ", + "icon": "clapperboard", "instructions": ( "Levels: high=actively watching, medium=video playing while" " doing something else, low=video paused or minimized." @@ -212,7 +231,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "gaming", "name": "Gaming", "description": "Games and entertainment", - "icon": "๐ŸŽฎ", + "emoji": "๐ŸŽฎ", + "icon": "gamepad-2", "instructions": ( "Levels: high=actively playing, medium=in menus or waiting," " low=game open but tabbed out." @@ -223,7 +243,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "social", "name": "Social Media", "description": "Social media browsing and interaction", - "icon": "๐Ÿ“ฑ", + "emoji": "๐Ÿ“ฑ", + "icon": "share-2", "instructions": ( "Levels: high=posting or actively engaging, medium=scrolling feed," " low=social app open but idle." @@ -234,7 +255,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "planning", "name": "Planning", "description": "Scheduling, calendar management, meeting preparation, and agenda setting", - "icon": "๐Ÿ“‹", + "emoji": "๐Ÿ“‹", + "icon": "calendar-check", "instructions": ( "Levels: high=actively scheduling or preparing agendas," " medium=reviewing calendar or event details," @@ -250,7 +272,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "productivity", "name": "Productivity", "description": "Spreadsheets, slides, and task management", - "icon": "๐Ÿ“Š", + "emoji": "๐Ÿ“Š", + "icon": "chart-column", "instructions": ( "Levels: high=actively editing or organizing, medium=reviewing data," " low=app open but not focused." @@ -262,7 +285,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "terminal", "name": "Terminal", "description": "Command line and shell sessions", - "icon": "โŒจ๏ธ", + "emoji": "โŒจ๏ธ", + "icon": "terminal", "instructions": ( "Levels: high=running commands or scripts, medium=reading output," " low=terminal open but idle." @@ -274,7 +298,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "design", "name": "Design", "description": "Design tools and image editing", - "icon": "๐ŸŽจ", + "emoji": "๐ŸŽจ", + "icon": "palette", "instructions": ( "Levels: high=actively creating or editing, medium=reviewing designs," " low=design tool open but idle." @@ -285,7 +310,8 @@ DEFAULT_ACTIVITIES: list[dict[str, str]] = [ "id": "music", "name": "Music", "description": "Music listening and audio", - "icon": "๐ŸŽต", + "emoji": "๐ŸŽต", + "icon": "music", "instructions": ( "Levels: high=actively choosing or browsing music," " medium=playlist running while working, low=ambient background audio." @@ -304,6 +330,27 @@ def get_default_activities() -> list[dict[str, str]]: return [dict(a) for a in DEFAULT_ACTIVITIES] +def _lucide_name_shaped(value: object) -> bool: + return isinstance(value, str) and bool(LUCIDE_ICON_NAME_RE.fullmatch(value)) + + +def _normalized_icon_fields(entry: dict[str, Any]) -> tuple[str | None, str | None]: + """Return canonical (emoji, icon) fields for a stored activity config entry.""" + emoji = entry.get("emoji") + icon = entry.get("icon") + + if not isinstance(emoji, str) or not emoji: + emoji = None + + if not isinstance(icon, str) or not icon: + return emoji, None + + if _lucide_name_shaped(icon): + return emoji, icon + + return emoji or icon, None + + def _get_activities_path(facet: str) -> Path: """Get the path to a facet's activities.jsonl file.""" return Path(get_journal()) / "facets" / facet / "activities" / "activities.jsonl" @@ -358,7 +405,8 @@ def get_facet_activities(facet: str) -> list[dict[str, Any]]: - id: Activity identifier - name: Display name - description: Activity description - - icon: Emoji icon (if predefined) + - emoji: Glyph fallback + - icon: Lucide icon name - priority: "high", "normal", or "low" - custom: True if user-created (not in defaults) - always_on: True if auto-included from defaults @@ -395,6 +443,8 @@ def get_facet_activities(facet: str) -> list[dict[str, Any]]: else: activity = {"id": activity_id, "custom": True} + emoji, icon = _normalized_icon_fields(fa) + # Apply facet overrides if "name" in fa: activity["name"] = fa["name"] @@ -402,8 +452,10 @@ def get_facet_activities(facet: str) -> list[dict[str, Any]]: activity["description"] = fa["description"] if "priority" in fa: activity["priority"] = fa["priority"] - if "icon" in fa: - activity["icon"] = fa["icon"] + if emoji is not None: + activity["emoji"] = emoji + if icon is not None: + activity["icon"] = icon if "instructions" in fa: activity["instructions"] = fa["instructions"] @@ -437,7 +489,8 @@ def save_facet_activities(facet: str, activities: list[dict[str, Any]]) -> None: - description: Activity description Optional for all: - priority: "high", "normal", or "low" - - icon: Emoji icon + - emoji: Glyph fallback + - icon: Lucide icon name - instructions: Detection/level instructions for the LLM """ # Build lookup for defaults to determine what needs to be stored @@ -482,6 +535,8 @@ def save_facet_activities(facet: str, activities: list[dict[str, Any]]) -> None: entry["instructions"] = activity["instructions"] if activity.get("priority"): entry["priority"] = activity["priority"] + if activity.get("emoji"): + entry["emoji"] = activity["emoji"] if activity.get("icon"): entry["icon"] = activity["icon"] @@ -490,6 +545,64 @@ def save_facet_activities(facet: str, activities: list[dict[str, Any]]) -> None: _save_activities_jsonl(facet, entries) +def migrate_custom_activity_icons_to_emoji( + *, dry_run: bool = False +) -> dict[str, int | bool]: + """Migrate legacy custom activity glyphs from icon to emoji. + + Stored custom activities used to treat ``icon`` as an emoji glyph. The + current shape reserves ``icon`` for Lucide names and stores glyphs in + ``emoji``. Predefined activities are intentionally left alone because + public write paths never stored icon overrides for them. + """ + defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES} + facets_dir = Path(get_journal()) / "facets" + result: dict[str, int | bool] = { + "dry_run": dry_run, + "files_scanned": 0, + "files_changed": 0, + "records_changed": 0, + } + if not facets_dir.exists(): + return result + + for path in sorted(facets_dir.glob("*/activities/activities.jsonl")): + facet = path.parent.parent.name + records = _load_activities_jsonl(facet) + result["files_scanned"] = int(result["files_scanned"]) + 1 + changed = False + + for record in records: + activity_id = record.get("id") + if not activity_id: + continue + if not (record.get("custom") or activity_id not in defaults_by_id): + continue + + record_changed = False + emoji, icon = _normalized_icon_fields(record) + if emoji is not None and record.get("emoji") != emoji: + record["emoji"] = emoji + record_changed = True + if icon is None and "icon" in record: + record.pop("icon", None) + record_changed = True + elif icon is not None and record.get("icon") != icon: + record["icon"] = icon + record_changed = True + + if record_changed: + changed = True + result["records_changed"] = int(result["records_changed"]) + 1 + + if changed: + result["files_changed"] = int(result["files_changed"]) + 1 + if not dry_run: + _save_activities_jsonl(facet, records) + + return result + + def get_default_activity_by_id(activity_id: str) -> dict[str, Any] | None: """Look up a predefined default activity by ID.""" for activity in DEFAULT_ACTIVITIES: @@ -539,6 +652,7 @@ def add_activity_to_facet( description: str | None = None, instructions: str | None = None, priority: str = "normal", + emoji: str | None = None, icon: str | None = None, ) -> dict[str, Any]: """Add an activity to a facet. @@ -553,7 +667,8 @@ def add_activity_to_facet( description: Activity description instructions: Detection/level instructions for the LLM priority: "high", "normal", or "low" - icon: Emoji icon + emoji: Glyph fallback + icon: Lucide icon name Returns: The added activity dict @@ -589,6 +704,8 @@ def add_activity_to_facet( activity["instructions"] = instructions if priority and priority != "normal": activity["priority"] = priority + if emoji: + activity["emoji"] = emoji if icon: activity["icon"] = icon @@ -630,6 +747,7 @@ def update_activity_in_facet( instructions: str | None = None, priority: str | None = None, name: str | None = None, + emoji: str | None = None, icon: str | None = None, ) -> dict[str, Any] | None: """Update an activity's configuration in a facet. @@ -641,7 +759,8 @@ def update_activity_in_facet( instructions: New detection/level instructions (None to keep existing) priority: New priority (None to keep existing) name: New name - only applies to custom activities - icon: New icon - only applies to custom activities + emoji: New glyph fallback - only applies to custom activities + icon: New Lucide icon name - only applies to custom activities Returns: Updated activity dict, or None if not found @@ -670,12 +789,20 @@ def update_activity_in_facet( else: activity["priority"] = priority - # Only allow name/icon changes for custom activities + # Only allow name/emoji/icon changes for custom activities if activity.get("custom") or activity_id not in defaults_by_id: if name is not None: activity["name"] = name + if emoji is not None: + if emoji: + activity["emoji"] = emoji + else: + activity.pop("emoji", None) if icon is not None: - activity["icon"] = icon + if icon: + activity["icon"] = icon + else: + activity.pop("icon", None) break diff --git a/tests/baselines/api/settings/activities-defaults.json b/tests/baselines/api/settings/activities-defaults.json index e4c4ee015..9ad23a67f 100644 --- a/tests/baselines/api/settings/activities-defaults.json +++ b/tests/baselines/api/settings/activities-defaults.json @@ -3,7 +3,9 @@ { "always_on": true, "description": "Chat, Slack, Discord, and text messaging", - "icon": "๐Ÿ’ฌ", + "emoji": "๐Ÿ’ฌ", + "icon": "messages-square", + "icon_svg": " ", "id": "messaging", "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", "name": "Messaging" @@ -11,7 +13,9 @@ { "always_on": true, "description": "Email reading and composition", - "icon": "๐Ÿ“ง", + "emoji": "๐Ÿ“ง", + "icon": "mail", + "icon_svg": " ", "id": "email", "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", "name": "Email" @@ -19,161 +23,207 @@ { "always_on": true, "description": "Video calls, in-person meetings, and conferences", - "icon": "๐Ÿ“…", + "emoji": "๐Ÿ“…", + "icon": "users", + "icon_svg": " ", "id": "meeting", "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", "name": "Meetings" }, { "description": "Books, PDFs, articles, highlights, and documentation", - "icon": "๐Ÿ“–", + "emoji": "๐Ÿ“–", + "icon": "book-open", + "icon_svg": " ", "id": "reading", "instructions": "Levels: high=focused reading, medium=skimming content, low=document open but attention elsewhere. Detect via: PDF viewers, article pages, documentation sites, reading apps, imported book highlights and annotations. Do not use for reading code โ€” that is coding.", "name": "Reading" }, { "description": "Command line and shell sessions", - "icon": "โŒจ๏ธ", + "emoji": "โŒจ๏ธ", + "icon": "terminal", + "icon_svg": " ", "id": "terminal", "instructions": "Levels: high=running commands or scripts, medium=reading output, low=terminal open but idle. Detect via: shell prompts, command output, tmux/screen sessions. If terminal use is clearly coding-related, prefer coding instead.", "name": "Terminal" }, { "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", - "icon": "๐Ÿค–", + "emoji": "๐Ÿค–", + "icon": "bot", + "icon_svg": " ", "id": "ai_conversation", "instructions": "Levels: high=actively prompting and reading responses, medium=reviewing AI output or refining prompts, low=AI chat open but idle. Detect via: AI assistant interfaces (ChatGPT, Claude, Gemini), imported AI conversation transcripts, prompt-response patterns. Do not confuse with messaging โ€” AI conversation involves a human interacting with an AI model, not person-to-person chat.", "name": "AI Conversation" }, { "description": "Design tools and image editing", - "icon": "๐ŸŽจ", + "emoji": "๐ŸŽจ", + "icon": "palette", + "icon_svg": " ", "id": "design", "instructions": "Levels: high=actively creating or editing, medium=reviewing designs, low=design tool open but idle. Detect via: design apps (Figma, Photoshop, etc), canvas editing.", "name": "Design" }, { "description": "Documents, notes, and long-form writing", - "icon": "โœ๏ธ", + "emoji": "โœ๏ธ", + "icon": "pencil-line", + "icon_svg": " ", "id": "writing", "instructions": "Levels: high=actively composing text, medium=editing/revising, low=document open but not being edited. Detect via: document editors, note apps, text content changing.", "name": "Writing" }, { "description": "Games and entertainment", - "icon": "๐ŸŽฎ", + "emoji": "๐ŸŽฎ", + "icon": "gamepad-2", + "icon_svg": " ", "id": "gaming", "instructions": "Levels: high=actively playing, medium=in menus or waiting, low=game open but tabbed out. Detect via: game window, controller input, game UI elements.", "name": "Gaming" }, { "description": "Music listening and audio", - "icon": "๐ŸŽต", + "emoji": "๐ŸŽต", + "icon": "music", + "icon_svg": " ", "id": "music", "instructions": "Levels: high=actively choosing or browsing music, medium=playlist running while working, low=ambient background audio. Detect via: music player UI, audio playback indicators.", "name": "Music" }, { "description": "Programming, code review, and debugging", - "icon": "๐Ÿ’ป", + "emoji": "๐Ÿ’ป", + "icon": "code-xml", + "icon_svg": " ", "id": "coding", "instructions": "Levels: high=writing or debugging code, medium=reading/reviewing code, low=IDE or editor open but not focused. Detect via: editors, terminals with dev tools, AI coding assistants, git operations. Includes focused code reading and thinking.", "name": "Coding" }, { "description": "Scheduling, calendar management, meeting preparation, and agenda setting", - "icon": "๐Ÿ“‹", + "emoji": "๐Ÿ“‹", + "icon": "calendar-check", + "icon_svg": " ", "id": "planning", "instructions": "Levels: high=actively scheduling or preparing agendas, medium=reviewing calendar or event details, low=calendar visible but not being interacted with. Detect via: calendar apps, scheduling interfaces, event creation, imported calendar events, meeting invitations, agenda drafting. Use for scheduling and preparation work. Do not confuse with meeting โ€” planning is the preparation, meeting is the actual synchronous interaction.", "name": "Planning" }, { "description": "Social media browsing and interaction", - "icon": "๐Ÿ“ฑ", + "emoji": "๐Ÿ“ฑ", + "icon": "share-2", + "icon_svg": " ", "id": "social", "instructions": "Levels: high=posting or actively engaging, medium=scrolling feed, low=social app open but idle. Detect via: social media sites/apps, feed content, post composition.", "name": "Social Media" }, { "description": "Spreadsheets, slides, and task management", - "icon": "๐Ÿ“Š", + "emoji": "๐Ÿ“Š", + "icon": "chart-column", + "icon_svg": " ", "id": "productivity", "instructions": "Levels: high=actively editing or organizing, medium=reviewing data, low=app open but not focused. Detect via: spreadsheet/slide editors, project management tools, task boards.", "name": "Productivity" }, { "description": "Watching videos and streaming content", - "icon": "๐ŸŽฌ", + "emoji": "๐ŸŽฌ", + "icon": "clapperboard", + "icon_svg": " ", "id": "video", "instructions": "Levels: high=actively watching, medium=video playing while doing something else, low=video paused or minimized. Detect via: video player UI, streaming sites, playback controls.", "name": "Video" }, { "description": "Web browsing, research, and reading online", - "icon": "๐ŸŒ", + "emoji": "๐ŸŒ", + "icon": "globe", + "icon_svg": " ", "id": "browsing", "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", "name": "Browsing" }, { "description": "a call you have planned", - "icon": "๐Ÿ“ž", + "emoji": "๐Ÿ“ž", + "icon": "phone", + "icon_svg": " ", "id": "call", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "call" }, { "description": "a celebration on the calendar", - "icon": "๐ŸŽ‰", + "emoji": "๐ŸŽ‰", + "icon": "party-popper", + "icon_svg": " ", "id": "celebration", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "celebration" }, { "description": "a deadline you are working toward", - "icon": "โฐ", + "emoji": "โฐ", + "icon": "alarm-clock", + "icon_svg": " ", "id": "deadline", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "deadline" }, { "description": "a medical appointment on your calendar", - "icon": "๐Ÿฉบ", + "emoji": "๐Ÿฉบ", + "icon": "stethoscope", + "icon_svg": " ", "id": "doctor_appointment", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "doctor appointment" }, { "description": "a reminder for something upcoming", - "icon": "๐Ÿ””", + "emoji": "๐Ÿ””", + "icon": "bell", + "icon_svg": " ", "id": "reminder", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "reminder" }, { "description": "an appointment on your calendar", - "icon": "๐Ÿ“Œ", + "emoji": "๐Ÿ“Œ", + "icon": "pin", + "icon_svg": " ", "id": "appointment", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "appointment" }, { "description": "an errand you plan to do", - "icon": "๐Ÿงพ", + "emoji": "๐Ÿงพ", + "icon": "receipt", + "icon_svg": " ", "id": "errand", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "errand" }, { "description": "an event you plan to attend", - "icon": "๐ŸŽŸ๏ธ", + "emoji": "๐ŸŽŸ๏ธ", + "icon": "ticket", + "icon_svg": " ", "id": "event", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "event" }, { "description": "travel you have planned", - "icon": "โœˆ๏ธ", + "emoji": "โœˆ๏ธ", + "icon": "plane", + "icon_svg": " ", "id": "travel", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "travel" diff --git a/tests/baselines/api/settings/facet-activities.json b/tests/baselines/api/settings/facet-activities.json index 6961b74d4..857365b6e 100644 --- a/tests/baselines/api/settings/facet-activities.json +++ b/tests/baselines/api/settings/facet-activities.json @@ -4,7 +4,9 @@ "always_on": true, "custom": false, "description": "Chat, Slack, Discord, and text messaging", - "icon": "๐Ÿ’ฌ", + "emoji": "๐Ÿ’ฌ", + "icon": "messages-square", + "icon_svg": " ", "id": "messaging", "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", "name": "Messaging", @@ -14,7 +16,9 @@ "always_on": true, "custom": false, "description": "Email reading and composition", - "icon": "๐Ÿ“ง", + "emoji": "๐Ÿ“ง", + "icon": "mail", + "icon_svg": " ", "id": "email", "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", "name": "Email", @@ -24,7 +28,9 @@ "always_on": true, "custom": false, "description": "Meetings and sync calls", - "icon": "๐Ÿ“…", + "emoji": "๐Ÿ“…", + "icon": "users", + "icon_svg": " ", "id": "meeting", "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", "name": "Meetings", @@ -33,6 +39,7 @@ { "custom": true, "description": "Software development and coding", + "icon_svg": null, "id": "engineering", "name": "Engineering", "priority": "high" @@ -42,7 +49,9 @@ { "always_on": true, "description": "Chat, Slack, Discord, and text messaging", - "icon": "๐Ÿ’ฌ", + "emoji": "๐Ÿ’ฌ", + "icon": "messages-square", + "icon_svg": " ", "id": "messaging", "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", "name": "Messaging" @@ -50,7 +59,9 @@ { "always_on": true, "description": "Email reading and composition", - "icon": "๐Ÿ“ง", + "emoji": "๐Ÿ“ง", + "icon": "mail", + "icon_svg": " ", "id": "email", "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", "name": "Email" @@ -58,161 +69,207 @@ { "always_on": true, "description": "Video calls, in-person meetings, and conferences", - "icon": "๐Ÿ“…", + "emoji": "๐Ÿ“…", + "icon": "users", + "icon_svg": " ", "id": "meeting", "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", "name": "Meetings" }, { "description": "Books, PDFs, articles, highlights, and documentation", - "icon": "๐Ÿ“–", + "emoji": "๐Ÿ“–", + "icon": "book-open", + "icon_svg": " ", "id": "reading", "instructions": "Levels: high=focused reading, medium=skimming content, low=document open but attention elsewhere. Detect via: PDF viewers, article pages, documentation sites, reading apps, imported book highlights and annotations. Do not use for reading code โ€” that is coding.", "name": "Reading" }, { "description": "Command line and shell sessions", - "icon": "โŒจ๏ธ", + "emoji": "โŒจ๏ธ", + "icon": "terminal", + "icon_svg": " ", "id": "terminal", "instructions": "Levels: high=running commands or scripts, medium=reading output, low=terminal open but idle. Detect via: shell prompts, command output, tmux/screen sessions. If terminal use is clearly coding-related, prefer coding instead.", "name": "Terminal" }, { "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", - "icon": "๐Ÿค–", + "emoji": "๐Ÿค–", + "icon": "bot", + "icon_svg": " ", "id": "ai_conversation", "instructions": "Levels: high=actively prompting and reading responses, medium=reviewing AI output or refining prompts, low=AI chat open but idle. Detect via: AI assistant interfaces (ChatGPT, Claude, Gemini), imported AI conversation transcripts, prompt-response patterns. Do not confuse with messaging โ€” AI conversation involves a human interacting with an AI model, not person-to-person chat.", "name": "AI Conversation" }, { "description": "Design tools and image editing", - "icon": "๐ŸŽจ", + "emoji": "๐ŸŽจ", + "icon": "palette", + "icon_svg": " ", "id": "design", "instructions": "Levels: high=actively creating or editing, medium=reviewing designs, low=design tool open but idle. Detect via: design apps (Figma, Photoshop, etc), canvas editing.", "name": "Design" }, { "description": "Documents, notes, and long-form writing", - "icon": "โœ๏ธ", + "emoji": "โœ๏ธ", + "icon": "pencil-line", + "icon_svg": " ", "id": "writing", "instructions": "Levels: high=actively composing text, medium=editing/revising, low=document open but not being edited. Detect via: document editors, note apps, text content changing.", "name": "Writing" }, { "description": "Games and entertainment", - "icon": "๐ŸŽฎ", + "emoji": "๐ŸŽฎ", + "icon": "gamepad-2", + "icon_svg": " ", "id": "gaming", "instructions": "Levels: high=actively playing, medium=in menus or waiting, low=game open but tabbed out. Detect via: game window, controller input, game UI elements.", "name": "Gaming" }, { "description": "Music listening and audio", - "icon": "๐ŸŽต", + "emoji": "๐ŸŽต", + "icon": "music", + "icon_svg": " ", "id": "music", "instructions": "Levels: high=actively choosing or browsing music, medium=playlist running while working, low=ambient background audio. Detect via: music player UI, audio playback indicators.", "name": "Music" }, { "description": "Programming, code review, and debugging", - "icon": "๐Ÿ’ป", + "emoji": "๐Ÿ’ป", + "icon": "code-xml", + "icon_svg": " ", "id": "coding", "instructions": "Levels: high=writing or debugging code, medium=reading/reviewing code, low=IDE or editor open but not focused. Detect via: editors, terminals with dev tools, AI coding assistants, git operations. Includes focused code reading and thinking.", "name": "Coding" }, { "description": "Scheduling, calendar management, meeting preparation, and agenda setting", - "icon": "๐Ÿ“‹", + "emoji": "๐Ÿ“‹", + "icon": "calendar-check", + "icon_svg": " ", "id": "planning", "instructions": "Levels: high=actively scheduling or preparing agendas, medium=reviewing calendar or event details, low=calendar visible but not being interacted with. Detect via: calendar apps, scheduling interfaces, event creation, imported calendar events, meeting invitations, agenda drafting. Use for scheduling and preparation work. Do not confuse with meeting โ€” planning is the preparation, meeting is the actual synchronous interaction.", "name": "Planning" }, { "description": "Social media browsing and interaction", - "icon": "๐Ÿ“ฑ", + "emoji": "๐Ÿ“ฑ", + "icon": "share-2", + "icon_svg": " ", "id": "social", "instructions": "Levels: high=posting or actively engaging, medium=scrolling feed, low=social app open but idle. Detect via: social media sites/apps, feed content, post composition.", "name": "Social Media" }, { "description": "Spreadsheets, slides, and task management", - "icon": "๐Ÿ“Š", + "emoji": "๐Ÿ“Š", + "icon": "chart-column", + "icon_svg": " ", "id": "productivity", "instructions": "Levels: high=actively editing or organizing, medium=reviewing data, low=app open but not focused. Detect via: spreadsheet/slide editors, project management tools, task boards.", "name": "Productivity" }, { "description": "Watching videos and streaming content", - "icon": "๐ŸŽฌ", + "emoji": "๐ŸŽฌ", + "icon": "clapperboard", + "icon_svg": " ", "id": "video", "instructions": "Levels: high=actively watching, medium=video playing while doing something else, low=video paused or minimized. Detect via: video player UI, streaming sites, playback controls.", "name": "Video" }, { "description": "Web browsing, research, and reading online", - "icon": "๐ŸŒ", + "emoji": "๐ŸŒ", + "icon": "globe", + "icon_svg": " ", "id": "browsing", "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", "name": "Browsing" }, { "description": "a call you have planned", - "icon": "๐Ÿ“ž", + "emoji": "๐Ÿ“ž", + "icon": "phone", + "icon_svg": " ", "id": "call", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "call" }, { "description": "a celebration on the calendar", - "icon": "๐ŸŽ‰", + "emoji": "๐ŸŽ‰", + "icon": "party-popper", + "icon_svg": " ", "id": "celebration", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "celebration" }, { "description": "a deadline you are working toward", - "icon": "โฐ", + "emoji": "โฐ", + "icon": "alarm-clock", + "icon_svg": " ", "id": "deadline", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "deadline" }, { "description": "a medical appointment on your calendar", - "icon": "๐Ÿฉบ", + "emoji": "๐Ÿฉบ", + "icon": "stethoscope", + "icon_svg": " ", "id": "doctor_appointment", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "doctor appointment" }, { "description": "a reminder for something upcoming", - "icon": "๐Ÿ””", + "emoji": "๐Ÿ””", + "icon": "bell", + "icon_svg": " ", "id": "reminder", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "reminder" }, { "description": "an appointment on your calendar", - "icon": "๐Ÿ“Œ", + "emoji": "๐Ÿ“Œ", + "icon": "pin", + "icon_svg": " ", "id": "appointment", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "appointment" }, { "description": "an errand you plan to do", - "icon": "๐Ÿงพ", + "emoji": "๐Ÿงพ", + "icon": "receipt", + "icon_svg": " ", "id": "errand", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "errand" }, { "description": "an event you plan to attend", - "icon": "๐ŸŽŸ๏ธ", + "emoji": "๐ŸŽŸ๏ธ", + "icon": "ticket", + "icon_svg": " ", "id": "event", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "event" }, { "description": "travel you have planned", - "icon": "โœˆ๏ธ", + "emoji": "โœˆ๏ธ", + "icon": "plane", + "icon_svg": " ", "id": "travel", "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", "name": "travel" diff --git a/tests/test_activities.py b/tests/test_activities.py index 2364d5bb0..0524597d4 100644 --- a/tests/test_activities.py +++ b/tests/test_activities.py @@ -25,6 +25,8 @@ def test_get_default_activities(): assert "id" in activity assert "name" in activity assert "description" in activity + assert "emoji" in activity + assert "icon" in activity # Check some known activities exist ids = [a["id"] for a in defaults] @@ -39,6 +41,16 @@ def test_get_default_activities(): assert len(activity["instructions"]) > 0 +def test_default_activities_have_canonical_icon_shape(): + """Default activities keep emoji fallback and explicit Lucide icon name.""" + from solstone.think.activities import DEFAULT_ACTIVITIES, LUCIDE_ICON_NAME_RE + + assert len(DEFAULT_ACTIVITIES) == 25 + for activity in DEFAULT_ACTIVITIES: + assert activity["emoji"] + assert LUCIDE_ICON_NAME_RE.fullmatch(activity["icon"]) + + def test_get_default_activities_returns_copy(): """Test that get_default_activities returns a copy, not the original.""" from solstone.think.activities import get_default_activities @@ -204,6 +216,8 @@ def test_facet_activities_roundtrip(monkeypatch): "description": "A custom activity", "instructions": "Custom activity detection hints", "custom": True, + "emoji": "๐ŸŽฏ", + "icon": "sparkles", }, ] save_facet_activities("test_facet", activities) @@ -243,6 +257,91 @@ def test_facet_activities_roundtrip(monkeypatch): assert custom["custom"] is True assert custom["name"] == "Custom" assert custom["instructions"] == "Custom activity detection hints" + assert custom["emoji"] == "๐ŸŽฏ" + assert custom["icon"] == "sparkles" + + +def test_get_facet_activities_normalizes_legacy_custom_icon_without_writing( + monkeypatch, +): + """Legacy custom records with glyph in icon are normalized in memory only.""" + from solstone.think.activities import get_facet_activities + + with tempfile.TemporaryDirectory() as tmpdir: + monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) + activities_dir = Path(tmpdir) / "facets" / "work" / "activities" + activities_dir.mkdir(parents=True) + activities_file = activities_dir / "activities.jsonl" + activities_file.write_text( + json.dumps( + { + "id": "legacy_custom", + "custom": True, + "name": "Legacy custom", + "description": "Old stored shape", + "icon": "๐ŸŽฏ", + } + ) + + "\n", + encoding="utf-8", + ) + + activities = get_facet_activities("work") + + legacy = next( + activity for activity in activities if activity["id"] == "legacy_custom" + ) + assert legacy["emoji"] == "๐ŸŽฏ" + assert "icon" not in legacy + assert '"icon":' in activities_file.read_text(encoding="utf-8") + assert '"emoji":' not in activities_file.read_text(encoding="utf-8") + + +def test_migrate_custom_activity_icons_to_emoji_is_idempotent(monkeypatch): + from solstone.think.activities import migrate_custom_activity_icons_to_emoji + + with tempfile.TemporaryDirectory() as tmpdir: + monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) + activities_dir = Path(tmpdir) / "facets" / "work" / "activities" + activities_dir.mkdir(parents=True) + activities_file = activities_dir / "activities.jsonl" + activities_file.write_text( + json.dumps( + { + "id": "legacy_custom", + "custom": True, + "name": "Legacy custom", + "description": "Old stored shape", + "icon": "๐ŸŽฏ", + } + ) + + "\n", + encoding="utf-8", + ) + + first = migrate_custom_activity_icons_to_emoji() + second = migrate_custom_activity_icons_to_emoji() + + assert first["files_scanned"] == 1 + assert first["files_changed"] == 1 + assert first["records_changed"] == 1 + assert second["files_scanned"] == 1 + assert second["files_changed"] == 0 + assert second["records_changed"] == 0 + + records = [ + json.loads(line) + for line in activities_file.read_text(encoding="utf-8").splitlines() + ] + assert records == [ + { + "id": "legacy_custom", + "custom": True, + "name": "Legacy custom", + "description": "Old stored shape", + "emoji": "๐ŸŽฏ", + } + ] def test_add_activity_to_facet(monkeypatch): diff --git a/tests/test_app_activities.py b/tests/test_app_activities.py index e27622c80..dc5270f61 100644 --- a/tests/test_app_activities.py +++ b/tests/test_app_activities.py @@ -8,10 +8,12 @@ import json import pytest from solstone.apps.activities.routes import ( - _GENERIC_ACTIVITY_ICON, + _GENERIC_ACTIVITY_EMOJI, + _GENERIC_ACTIVITY_LUCIDE, _enrich_activity_record, activities_bp, ) +from solstone.convey.icons import lucide_svg @pytest.fixture @@ -63,7 +65,9 @@ class TestActivitiesDayRoutes: data = resp.get_json()["items"] coding = next(a for a in data if a["activity"] == "coding") assert coding["name"] != "" + assert coding["emoji"] != "" assert coding["icon"] != "" + assert "