From 2000e30a6e73838d406f21b8f5a248d64f4c324f Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Tue, 12 May 2026 17:28:14 +0200 Subject: [PATCH 1/9] Update justfile for demo user reset; refactor RPC server for intent handling; enhance invoice management with new methods; implement date coercion in CRUD operations; add LLM configuration and document parsing; establish database lifecycle management; improve intent serialization and dispatching logic. --- .cursor/rules/python-imports.mdc | 24 + justfile | 2 +- tuttle-electron/src/api/entity.ts | 17 + .../components/invoicing/InvoicingView.tsx | 26 +- tuttle-rpc.spec | 19 +- tuttle/app/clients/intent.py | 17 +- tuttle/app/contacts/intent.py | 13 +- tuttle/app/contracts/intent.py | 12 +- tuttle/app/core/abstractions.py | 160 +- tuttle/app/core/dispatch.py | 144 ++ tuttle/app/core/rpc_utils.py | 83 + tuttle/app/db/__init__.py | 0 tuttle/app/db/intent.py | 16 + tuttle/app/demo/__init__.py | 0 tuttle/app/demo/intent.py | 36 + tuttle/app/invoicing/data_source.py | 13 + tuttle/app/invoicing/intent.py | 118 +- tuttle/app/llm/__init__.py | 0 tuttle/app/llm/intent.py | 37 + tuttle/app/preferences/intent.py | 213 +-- tuttle/app/projects/intent.py | 10 +- tuttle/app/salary/intent.py | 15 + tuttle/app/settings/__init__.py | 0 tuttle/app/settings/intent.py | 25 + tuttle/app/tax/intent.py | 15 +- tuttle/app/timeline/intent.py | 4 +- tuttle/app/timetracking/aggregation.py | 150 ++ tuttle/app/timetracking/intent.py | 181 ++- tuttle/app/users/__init__.py | 0 tuttle/app/users/intent.py | 260 +++ tuttle/demo.py | 2 + tuttle/kpi.py | 23 + tuttle/model.py | 125 +- tuttle/rpc_server.py | 1407 +---------------- tuttle_tests/test_app_start.py | 24 +- tuttle_tests/test_rpc_dispatch.py | 230 +++ 36 files changed, 1730 insertions(+), 1691 deletions(-) create mode 100644 .cursor/rules/python-imports.mdc create mode 100644 tuttle/app/core/dispatch.py create mode 100644 tuttle/app/core/rpc_utils.py create mode 100644 tuttle/app/db/__init__.py create mode 100644 tuttle/app/db/intent.py create mode 100644 tuttle/app/demo/__init__.py create mode 100644 tuttle/app/demo/intent.py create mode 100644 tuttle/app/llm/__init__.py create mode 100644 tuttle/app/llm/intent.py create mode 100644 tuttle/app/settings/__init__.py create mode 100644 tuttle/app/settings/intent.py create mode 100644 tuttle/app/timetracking/aggregation.py create mode 100644 tuttle/app/users/__init__.py create mode 100644 tuttle/app/users/intent.py create mode 100644 tuttle_tests/test_rpc_dispatch.py diff --git a/.cursor/rules/python-imports.mdc b/.cursor/rules/python-imports.mdc new file mode 100644 index 00000000..9eaf38f3 --- /dev/null +++ b/.cursor/rules/python-imports.mdc @@ -0,0 +1,24 @@ +--- +description: Python import conventions — no imports inside function bodies +globs: "**/*.py" +alwaysApply: false +--- + +# Python Imports + +All imports MUST be at module level (top of file). Never inside function bodies. + +```python +# ❌ BAD +def get_data(): + from tuttle.app.core.formatting import fmt_currency + return fmt_currency(value, "EUR") + +# ✅ GOOD +from tuttle.app.core.formatting import fmt_currency + +def get_data(): + return fmt_currency(value, "EUR") +``` + +If a circular import exists, fix the architecture — do not work around it with lazy imports. diff --git a/justfile b/justfile index e8b905a5..62e8eb24 100644 --- a/justfile +++ b/justfile @@ -72,4 +72,4 @@ deps-all: deps deps-node # Reset the demo user data demo-reset: - {{python}} -c "from tuttle.rpc_server import _dispatch, _ensure_db; _ensure_db(); _dispatch('demo.reset', {}); print('Demo user reset')" + {{python}} -c "from tuttle.app.demo.intent import DemoIntent; DemoIntent().reset(); print('Demo user reset')" diff --git a/tuttle-electron/src/api/entity.ts b/tuttle-electron/src/api/entity.ts index 1e294283..b012824f 100644 --- a/tuttle-electron/src/api/entity.ts +++ b/tuttle-electron/src/api/entity.ts @@ -38,6 +38,23 @@ export function entity(e: Entity, key: string): Entity | null { return null; } +/** Traverse a dot-separated path through nested entities, e.g. "contract.client.name" */ +export function deep(e: Entity, path: string): unknown { + const parts = path.split("."); + let cur: unknown = e; + for (const p of parts) { + if (cur == null || typeof cur !== "object") return null; + cur = (cur as Record)[p]; + } + return cur; +} + +export function deepStr(e: Entity, path: string): string { + const v = deep(e, path); + if (v == null) return ""; + return String(v); +} + export function list(e: Entity, key: string): Entity[] { const v = e[key]; if (Array.isArray(v)) return v as Entity[]; diff --git a/tuttle-electron/src/components/invoicing/InvoicingView.tsx b/tuttle-electron/src/components/invoicing/InvoicingView.tsx index 1961b55a..d454e79e 100644 --- a/tuttle-electron/src/components/invoicing/InvoicingView.tsx +++ b/tuttle-electron/src/components/invoicing/InvoicingView.tsx @@ -5,7 +5,7 @@ import { Plus, Clock, } from "lucide-react"; import { rpc, readFileAsDataURL } from "../../api/rpc"; -import { str, num, bool, list as entityList, formatDate, invoiceStatus } from "../../api/entity"; +import { str, num, bool, list as entityList, formatDate, invoiceStatus, deepStr } from "../../api/entity"; import { StatusBadge } from "../shared/StatusBadge"; import { ViewModeToggle } from "../shared/ViewModeToggle"; import { KanbanBoard, useStageStore, type BoardColumn } from "../shared/KanbanBoard"; @@ -56,8 +56,8 @@ export function InvoicingView() { if (!search) return true; const q = search.toLowerCase(); return str(inv, "number").toLowerCase().includes(q) - || str(inv, "client_name").toLowerCase().includes(q) - || str(inv, "project_title").toLowerCase().includes(q); + || deepStr(inv, "contract.client.name").toLowerCase().includes(q) + || deepStr(inv, "project.title").toLowerCase().includes(q); } const filtered = invoices.filter((inv) => @@ -325,10 +325,10 @@ function InvoiceRow({ invoice, isSelected, isHighlighted, onSelect }: { invoice:
- {str(invoice, "client_name") || "No client"} - {str(invoice, "project_title") && ( + {deepStr(invoice, "contract.client.name") || "No client"} + {deepStr(invoice, "project.title") && ( <>· - {str(invoice, "project_title")} + {deepStr(invoice, "project.title")} )}
@@ -342,16 +342,16 @@ function InvoiceCard({ invoice }: { invoice: Entity; color: string }) { {str(invoice, "number") || "Draft"} {str(invoice, "total_formatted")} - {str(invoice, "client_name") && ( + {deepStr(invoice, "contract.client.name") && (
- {str(invoice, "client_name")} + {deepStr(invoice, "contract.client.name")}
)} - {str(invoice, "project_title") && ( + {deepStr(invoice, "project.title") && (
- {str(invoice, "project_title")} + {deepStr(invoice, "project.title")}
)}
@@ -398,7 +398,7 @@ function InvoiceDetail({ invoice, onToggleSent, onTogglePaid, onToggleCancelled

{str(invoice, "number") || "Draft"}

- {str(invoice, "client_name") || "No client"} + {deepStr(invoice, "contract.client.name") || "No client"}
@@ -458,8 +458,8 @@ function InvoiceDetail({ invoice, onToggleSent, onTogglePaid, onToggleCancelled
} label="Date" value={formatDate(str(invoice, "date"))} /> - } label="Project" value={str(invoice, "project_title") || "—"} /> - } label="Contract" value={str(invoice, "contract_title") || "—"} /> + } label="Project" value={deepStr(invoice, "project.title") || "—"} /> + } label="Contract" value={deepStr(invoice, "contract.title") || "—"} /> } label="Currency" value={str(invoice, "currency") || "EUR"} />
diff --git a/tuttle-rpc.spec b/tuttle-rpc.spec index 8cd4fdd1..69cfec3c 100644 --- a/tuttle-rpc.spec +++ b/tuttle-rpc.spec @@ -36,15 +36,26 @@ if _rfc_spec and _rfc_spec.submodule_search_locations: # --------------------------------------------------------------------------- hiddenimports = [ - # Intent classes (loaded on first RPC call) - "tuttle.app.contacts.intent", + # Intent classes (loaded dynamically by the dispatcher on first RPC call) + "tuttle.app.auth.intent", "tuttle.app.clients.intent", + "tuttle.app.contacts.intent", "tuttle.app.contracts.intent", - "tuttle.app.projects.intent", + "tuttle.app.dashboard.intent", + "tuttle.app.db.intent", + "tuttle.app.demo.intent", "tuttle.app.invoicing.intent", "tuttle.app.invoicing.data_source", - "tuttle.app.dashboard.intent", + "tuttle.app.llm.intent", + "tuttle.app.preferences.intent", + "tuttle.app.projects.intent", + "tuttle.app.salary.intent", + "tuttle.app.settings.intent", + "tuttle.app.tax.intent", + "tuttle.app.timetracking.intent", "tuttle.app.timeline.intent", + "tuttle.app.users.intent", + # Supporting modules "tuttle.app.core.database_storage_impl", "tuttle.app.core.formatting", "tuttle.model", diff --git a/tuttle/app/clients/intent.py b/tuttle/app/clients/intent.py index 12b9a348..f0e068ab 100644 --- a/tuttle/app/clients/intent.py +++ b/tuttle/app/clients/intent.py @@ -12,6 +12,7 @@ class ClientsIntent(CrudIntent): deletion_guards = [ ("contracts", "contracts", lambda c: c.title), ] + __save_skip__ = {"contracts"} def __init__(self): super().__init__() @@ -21,24 +22,10 @@ def get_all_contacts_as_map(self): """Delegate to ContactsIntent for cross-entity lookup.""" return self._contacts_intent.get_all_as_map() - def save_client(self, client: Client) -> IntentResult: - """Validate and save a client.""" + def _validated_save(self, client: Client) -> IntentResult: if not client.name: return IntentResult( was_intent_successful=False, error_msg="Please provide the client's name", ) - if ( - not client.invoicing_contact.first_name - or not client.invoicing_contact.last_name - ): - return IntentResult( - was_intent_successful=False, - error_msg="A contact name is required.", - ) - if client.invoicing_contact.address.is_empty: - return IntentResult( - was_intent_successful=False, - error_msg="Please specify the contact address.", - ) return self.save(client) diff --git a/tuttle/app/contacts/intent.py b/tuttle/app/contacts/intent.py index 300010dc..ed21977b 100644 --- a/tuttle/app/contacts/intent.py +++ b/tuttle/app/contacts/intent.py @@ -1,6 +1,6 @@ from ..core.abstractions import CrudIntent from ..core.intent_result import IntentResult -from ...model import Contact +from ...model import Address, Contact class ContactsIntent(CrudIntent): @@ -11,21 +11,18 @@ class ContactsIntent(CrudIntent): deletion_guards = [ ("invoicing_contact_of", "clients", lambda c: c.name), ] + __save_nested__ = {"address": Address} + __save_skip__ = {"invoicing_contact_of"} - def save_contact(self, contact: Contact) -> IntentResult: - """Validate and save a contact.""" + def _validated_save(self, contact: Contact) -> IntentResult: if not contact.first_name or not contact.last_name: return IntentResult( was_intent_successful=False, error_msg="Saving contact failed. A name is required.", ) - if contact.address.is_empty: + if contact.address and contact.address.is_empty: return IntentResult( was_intent_successful=False, error_msg="Saving contact failed. Please specify the address.", ) return self.save(contact) - - def delete_contact(self, contact_id) -> IntentResult: - """Alias kept for backward compatibility.""" - return self.delete(contact_id) diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index fedd1d05..5a0937fb 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -15,6 +15,7 @@ class ContractsIntent(CrudIntent): ("projects", "projects", lambda p: p.title), ("invoices", "invoices", lambda i: i.number or f"#{i.id}"), ] + __save_skip__ = {"client", "projects", "invoices"} def __init__(self): super().__init__() @@ -30,7 +31,7 @@ def get_all_contacts_as_map(self): return self._contacts_intent.get_all_as_map() def save_client(self, client: Client) -> IntentResult: - return self._clients_intent.save_client(client=client) + return self._clients_intent._validated_save(client=client) def get_default_currency(self) -> IntentResult: """Derive default contract currency from the user's operating country.""" @@ -39,13 +40,12 @@ def get_default_currency(self) -> IntentResult: country = users[0].operating_country if users else "Germany" ts = get_tax_system(country) return IntentResult(was_intent_successful=True, data=ts.currency) - except Exception as e: + except Exception: return IntentResult(was_intent_successful=True, data="EUR") # -- Contract-specific logic ----------------------------------------------- - def save_contract(self, contract: Contract) -> IntentResult: - """Validate and save a contract.""" + def _validated_save(self, contract: Contract) -> IntentResult: is_updating = contract.id is not None result = self.save(contract) if not result.was_intent_successful and is_updating: @@ -55,6 +55,4 @@ def save_contract(self, contract: Contract) -> IntentResult: result.log_message_if_any() return result - def toggle_complete_status(self, contract: Contract) -> IntentResult[Contract]: - """Toggles the completed status of the contract.""" - return self.toggle_completed(contract) + toggle_complete_status = CrudIntent.toggle_completed diff --git a/tuttle/app/core/abstractions.py b/tuttle/app/core/abstractions.py index 183b7852..a4d267b9 100644 --- a/tuttle/app/core/abstractions.py +++ b/tuttle/app/core/abstractions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime from typing import Any, Callable, List, Mapping, Optional, Type from abc import ABC, abstractmethod @@ -15,6 +16,21 @@ from .intent_result import IntentResult +def _coerce_dates(data: dict) -> dict: + """Convert ISO date strings to ``datetime.date`` for ``*_date`` keys.""" + for k in list(data): + if k.endswith("_date"): + v = data[k] + if v == "" or v is None: + data[k] = None + elif isinstance(v, str) and len(v) >= 10: + try: + data[k] = datetime.date.fromisoformat(v[:10]) + except ValueError: + pass + return data + + class DatabaseStorage(ABC): """Abstract class for database storage""" @@ -134,10 +150,11 @@ def create_session(self): ) def query(self, entity_type: Type[sqlmodel.SQLModel]) -> List: - """Queries the database for all instances of the given entity type""" + """Queries the database for all instances of the given entity type.""" logger.debug(f"querying {entity_type}") with self.create_session() as session: entities = session.exec(sqlmodel.select(entity_type)).all() + self._hydrate(entities) if len(entities) == 0: logger.warning(f"No instances of {entity_type} found") else: @@ -149,18 +166,43 @@ def query_by_id( entity_type: Type[sqlmodel.SQLModel], entity_id: int, ) -> Optional[sqlmodel.SQLModel]: - """Queries the database for an instance of the given entity type with the given id""" + """Queries the database for an instance of the given entity type with the given id.""" logger.debug(f"querying {entity_type} by id={entity_id}") with self.create_session() as session: entity = session.exec( sqlmodel.select(entity_type).where(entity_type.id == entity_id) ).one() + self._hydrate([entity]) if entity is None: logger.warning(f"No instance of {entity_type} found with id={entity_id}") else: logger.info(f"Found instance of {entity_type} with id={entity_id}") return entity + @staticmethod + def _hydrate(entities, _depth: int = 2): + """Touch relationship attributes while the session is still open. + + This forces SQLAlchemy to load them (via whatever lazy strategy the + model declares) so that ``to_rpc_dict()`` can access them after the + session closes. + """ + if _depth <= 0 or not entities: + return + sample = entities[0] + rels = getattr(sample, "__rpc_relationships__", None) + if not rels: + return + names = list(rels.keys()) if isinstance(rels, dict) else list(rels) + projections = rels if isinstance(rels, dict) else {n: None for n in names} + for entity in entities: + for name in names: + value = getattr(entity, name, None) + if value is None or projections[name] is not None: + continue + children = value if isinstance(value, list) else [value] + SQLModelDataSourceMixin._hydrate(children, _depth - 1) + def query_where( self, entity_type: Type[sqlmodel.SQLModel], @@ -242,20 +284,25 @@ def wrapped(*args, **kwargs): class CrudIntent(SQLModelDataSourceMixin, Intent): """Generic CRUD intent that combines data access and business logic. - Subclasses must set `entity_type` to the SQLModel class they manage. - Optionally set `entity_name` for human-readable error messages. + Subclasses must set ``entity_type`` to the SQLModel class they manage. + + Declarative hooks for ``save_from_dict``:: - To prevent deletion when related records exist, set ``deletion_guards`` - to a list of ``(relationship_attr, label, display_func)`` tuples. - *relationship_attr* is the attribute name on the entity that holds the - referencing collection, *label* is a human-readable noun (plural) for - the error message, and *display_func* extracts a short display string - from each related object. + __save_nested__ = {"address": Address} # nested relationship objects + __save_skip__ = {"some_backref"} # extra fields to ignore + + Override ``_validated_save`` to add validation before the final save. + + ``deletion_guards`` is a list of ``(relationship_attr, label, display_func)`` + tuples that produce user-friendly messages when related records still + reference this entity. """ entity_type: Type[sqlmodel.SQLModel] entity_name: str = "" deletion_guards: List[tuple] = [] + __save_nested__: dict = {} + __save_skip__: set = set() def __init__(self): SQLModelDataSourceMixin.__init__(self) @@ -372,11 +419,100 @@ def get_upcoming_as_map(self) -> Mapping[int, Any]: # -- Toggle helpers -------------------------------------------------------- - def toggle_completed(self, entity) -> IntentResult: - """Toggle is_completed and save. Rolls back on failure.""" + def toggle_completed(self, entity=None, *, id=None) -> IntentResult: + """Toggle is_completed and save. Accepts entity or id.""" + if entity is None: + if id is None: + return IntentResult( + was_intent_successful=False, error_msg="No entity or id provided" + ) + result = self.get_by_id(id) + if not result.was_intent_successful or not result.data: + return result + entity = result.data entity.is_completed = not entity.is_completed result = self.save(entity) if not result.was_intent_successful: entity.is_completed = not entity.is_completed result.data = entity return result + + # -- Generic dict → entity save -------------------------------------------- + + def save_from_dict(self, data: dict) -> IntentResult: + """Create or update an entity from a plain dict. + + Handles date coercion, FK references (``{rel: {id: N}}``), + nested relationships declared in ``__save_nested__``, and + create-vs-update branching. Calls ``_validated_save`` at the end. + """ + data = _coerce_dates(dict(data)) + entity_id = data.pop("id", None) + + # Resolve FK references: {"rel": {"id": N}} → rel_id = N + for k, v in list(data.items()): + if isinstance(v, dict) and set(v.keys()) == {"id"}: + fk = f"{k}_id" + if hasattr(self.entity_type, fk): + data[fk] = v["id"] + del data[k] + + # Extract declared nested relationship data + nested_raw: dict[str, dict] = {} + for field in self.__save_nested__: + nested_raw[field] = data.pop(field, None) or {} + data.pop(f"{field}_id", None) + + skip = self.__save_skip__ | set(self.__save_nested__) + clean = { + k: v for k, v in data.items() if not k.startswith("_") and k not in skip + } + + if entity_id: + result = self.get_by_id(entity_id) + if not result.was_intent_successful or not result.data: + return IntentResult( + was_intent_successful=False, + error_msg=f"{self.entity_name} not found", + ) + entity = result.data + for k, v in clean.items(): + setattr(entity, k, v) + self._apply_nested(entity, nested_raw) + else: + children = self._build_nested(nested_raw) + entity = self.entity_type(**clean, **children) + + return self._validated_save(entity) + + def _validated_save(self, entity) -> IntentResult: + """Hook for subclasses to add domain validation before saving.""" + return self.save(entity) + + def _apply_nested(self, entity, nested_raw: dict): + """Update or create nested relationship objects on an existing entity.""" + for field, raw in nested_raw.items(): + if not raw: + continue + model_cls = self.__save_nested__[field] + existing = getattr(entity, field, None) + if existing: + for k, v in raw.items(): + if k != "id" and not k.startswith("_"): + setattr(existing, k, v) + else: + clean = { + k: v for k, v in raw.items() if k != "id" and not k.startswith("_") + } + setattr(entity, field, model_cls(**clean)) + + def _build_nested(self, nested_raw: dict) -> dict: + """Construct nested objects for a new entity.""" + result = {} + for field, raw in nested_raw.items(): + model_cls = self.__save_nested__[field] + clean = { + k: v for k, v in raw.items() if k != "id" and not k.startswith("_") + } + result[field] = model_cls(**clean) + return result diff --git a/tuttle/app/core/dispatch.py b/tuttle/app/core/dispatch.py new file mode 100644 index 00000000..e1630cec --- /dev/null +++ b/tuttle/app/core/dispatch.py @@ -0,0 +1,144 @@ +"""Convention-based JSON-RPC dispatcher. + +Every RPC call ``domain.method_name`` is resolved to an intent method: + + tuttle.app.{domain}.intent.{Domain}Intent.{method_name} + +Method resolution tries suffixes in order: ``_from_dict``, exact, ``_as_map``. +Params are bound via ``inspect.signature`` with single-value fallback. +Return values are serialised through ``dump()`` (which honours ``to_rpc_dict()``). +""" + +import importlib +import inspect +from typing import Any, Callable, Dict + +from .rpc_utils import dump, unwrap, register_reset + + +# --------------------------------------------------------------------------- +# Intent singleton cache +# --------------------------------------------------------------------------- + +_intents: Dict[str, Any] = {} + + +def _clear_intents(): + _intents.clear() + + +register_reset(_clear_intents) + + +def _get_intent(domain: str): + if domain not in _intents: + mod_path = f"tuttle.app.{domain}.intent" + mod = importlib.import_module(mod_path) + # Find the Intent subclass defined in this module (not imported ones) + candidates = [ + obj + for name, obj in inspect.getmembers(mod, inspect.isclass) + if name.endswith("Intent") and obj.__module__ == mod.__name__ + ] + if len(candidates) != 1: + raise AttributeError( + f"Expected exactly 1 *Intent class in {mod_path}, " + f"found {[c.__name__ for c in candidates]}" + ) + _intents[domain] = candidates[0]() + return _intents[domain] + + +# --------------------------------------------------------------------------- +# Method resolution +# --------------------------------------------------------------------------- + +_SUFFIXES = ("_from_dict", "", "_as_map") + + +def _resolve_method(intent, method_name: str) -> Callable | None: + for suffix in _SUFFIXES: + fn = getattr(intent, method_name + suffix, None) + if fn is not None and callable(fn): + return fn + return None + + +# --------------------------------------------------------------------------- +# Introspective param binding +# --------------------------------------------------------------------------- + + +def _call(fn: Callable, params: dict): + sig = inspect.signature(fn) + positional = [ + p + for name, p in sig.parameters.items() + if name != "self" + and p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + ] + + if not positional: + return fn() + + try: + return fn(**params) + except TypeError: + pass + + if len(positional) == 1: + p = positional[0] + if p.name in params: + return fn(params[p.name]) + if len(params) == 1: + return fn(next(iter(params.values()))) + return fn(params) + + kwargs = {} + for p in positional: + if p.name in params: + kwargs[p.name] = params[p.name] + elif p.default is not inspect.Parameter.empty: + pass + else: + raise TypeError(f"Missing required param '{p.name}' for {fn.__name__}") + return fn(**kwargs) + + +# --------------------------------------------------------------------------- +# Result serialisation +# --------------------------------------------------------------------------- + + +def _serialize(result) -> dict: + if hasattr(result, "was_intent_successful"): + if not result.was_intent_successful: + return unwrap(result) + data = result.data + else: + data = result + return {"ok": True, "data": dump(data), "error": None} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def dispatch(method: str, params: dict) -> dict: + dot = method.find(".") + if dot < 0: + raise ValueError(f"Method must be 'domain.name', got: {method}") + domain, method_name = method[:dot], method[dot + 1 :] + + intent = _get_intent(domain) + fn = _resolve_method(intent, method_name) + if fn is None: + raise ValueError(f"No handler for '{method}'") + + return _serialize(_call(fn, params)) diff --git a/tuttle/app/core/rpc_utils.py b/tuttle/app/core/rpc_utils.py new file mode 100644 index 00000000..3a819f79 --- /dev/null +++ b/tuttle/app/core/rpc_utils.py @@ -0,0 +1,83 @@ +"""Shared RPC serialisation and envelope utilities. + +Generic protocol helpers — not domain logic. +""" + +import datetime +from decimal import Decimal +from typing import Any, Callable, Dict + +from tuttle.app_db import AppDatabase + + +def dump(obj: Any) -> Any: + """Recursively convert a Python value to JSON-safe primitives. + + Models with ``RpcMixin`` get relationship-aware serialisation via + ``to_rpc_dict()``. Plain SQLModel/Pydantic models fall back to + ``model_dump()`` (column fields only). + """ + if obj is None: + return None + if isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, Decimal): + return float(obj) + if isinstance(obj, (datetime.date, datetime.datetime)): + return obj.isoformat() + if isinstance(obj, datetime.timedelta): + return obj.total_seconds() + if isinstance(obj, dict): + return {str(k): dump(v) for k, v in obj.items()} + if isinstance(obj, list): + return [dump(v) for v in obj] + if hasattr(obj, "to_rpc_dict"): + return dump(obj.to_rpc_dict()) + if hasattr(obj, "model_dump"): + return dump(obj.model_dump()) + if hasattr(obj, "_asdict"): + return dump(obj._asdict()) + if hasattr(obj, "__dataclass_fields__"): + import dataclasses + + return dump(dataclasses.asdict(obj)) + if hasattr(obj, "value"): + return dump(obj.value) + return str(obj) + + +def unwrap(result) -> Dict[str, Any]: + """Convert an IntentResult to a ``{ok, data, error}`` envelope.""" + return { + "ok": result.was_intent_successful, + "data": dump(result.data), + "error": result.error_msg or None, + } + + +# --------------------------------------------------------------------------- +# Intent singleton reset registry +# --------------------------------------------------------------------------- + +_reset_fns: list[Callable] = [] + + +def register_reset(fn: Callable) -> None: + """Register a zero-arg callable that clears a domain's intent cache.""" + if fn not in _reset_fns: + _reset_fns.append(fn) + + +def reset_all() -> None: + """Invoke every registered reset callback (cross-domain cache flush).""" + for fn in _reset_fns: + fn() + + +# --------------------------------------------------------------------------- +# App-level DB accessor +# --------------------------------------------------------------------------- + + +def get_app_db(): + return AppDatabase() diff --git a/tuttle/app/db/__init__.py b/tuttle/app/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/app/db/intent.py b/tuttle/app/db/intent.py new file mode 100644 index 00000000..db93e50d --- /dev/null +++ b/tuttle/app/db/intent.py @@ -0,0 +1,16 @@ +"""Database lifecycle — ensure, existence check.""" + +from ..core.intent_result import IntentResult +from ..core.abstractions import get_active_db +from ..users.intent import UsersIntent + + +class DbIntent: + def ensure(self) -> IntentResult: + return UsersIntent().ensure_db() + + def exists(self) -> IntentResult: + return IntentResult( + was_intent_successful=True, + data=get_active_db().exists(), + ) diff --git a/tuttle/app/demo/__init__.py b/tuttle/app/demo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/app/demo/intent.py b/tuttle/app/demo/intent.py new file mode 100644 index 00000000..4625cb4e --- /dev/null +++ b/tuttle/app/demo/intent.py @@ -0,0 +1,36 @@ +"""Demo data lifecycle — install and reset.""" + +from loguru import logger + +from ..core.intent_result import IntentResult +from ..core.rpc_utils import reset_all +from ..preferences.model import PreferencesStorageKeys, DEFAULT_INVOICE_TEMPLATE +from ..users.intent import UsersIntent +from ...app_db import AppDatabase + + +class DemoIntent: + def install(self) -> IntentResult: + users = UsersIntent() + result = users.ensure_demo() + if result.was_intent_successful and result.data: + db_file = getattr(result.data, "db_file", "harry-tuttle.db") + users.switch(db_file=db_file) + return result + + def reset(self) -> IntentResult: + app_db = AppDatabase() + lang = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" + tmpl = ( + app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) + or DEFAULT_INVOICE_TEMPLATE + ) + app_db.remove_user("harry-tuttle.db") + reset_all() + + users = UsersIntent() + users.ensure_demo(invoice_language=lang, invoice_template=tmpl) + users.switch(db_file="harry-tuttle.db") + + reg = app_db.get_user_by_db_file("harry-tuttle.db") + return IntentResult(was_intent_successful=True, data=reg) diff --git a/tuttle/app/invoicing/data_source.py b/tuttle/app/invoicing/data_source.py index 5139acd0..1eeece1b 100644 --- a/tuttle/app/invoicing/data_source.py +++ b/tuttle/app/invoicing/data_source.py @@ -39,6 +39,19 @@ def get_invoices_for_project(self, project_id) -> IntentResult[List[Invoice]]: exception=e, ) + def get_invoice_by_id(self, invoice_id: int) -> IntentResult[Optional[Invoice]]: + """Fetch a single invoice by primary key.""" + try: + invoice = self.query_by_id(Invoice, invoice_id) + return IntentResult(was_intent_successful=True, data=invoice) + except Exception as ex: + return IntentResult( + was_intent_successful=False, + error_msg=f"Invoice with id={invoice_id} not found.", + log_message=f"InvoicingDataSource.get_invoice_by_id({invoice_id}): {ex}", + exception=ex, + ) + def get_all_invoices(self) -> IntentResult[List[Invoice]]: """Get all existing invoices diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 0551075b..8203ad95 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -1,53 +1,115 @@ -from typing import Mapping, Optional, Type, Union - +import datetime as _dt import textwrap from datetime import date from pathlib import Path +from typing import Mapping, Optional, Type, Union -from ..auth.data_source import UserDataSource -from ..core.abstractions import ClientStorage, Intent -from ..core.intent_result import IntentResult from loguru import logger from pandas import DataFrame + +from ..auth.data_source import UserDataSource +from ..auth.intent import AuthIntent +from ..core.abstractions import Intent +from ..core.intent_result import IntentResult +from ..preferences.intent import PreferencesIntent +from ..preferences.model import ( + DEFAULT_INVOICE_TEMPLATE, + INVOICE_TEMPLATES, + PreferencesStorageKeys, + SUPPORTED_INVOICE_LANGUAGES, +) from ..projects.intent import ProjectsIntent from ..timetracking.data_source import TimeTrackingDataFrameSource from ..timetracking.intent import TimeTrackingIntent - +from ...app_db import AppDatabase from ... import invoicing, mail, os_functions, rendering, timetracking from ...model import Invoice, InvoiceItem, Project, Timesheet, User from .data_source import InvoicingDataSource -from ..auth.intent import AuthIntent -from ..preferences.intent import PreferencesIntent -from ..preferences.model import DEFAULT_INVOICE_TEMPLATE class InvoicingIntent(Intent): - """Handles Invoicing C_R_U_D intents""" + """Invoicing CRUD, creation orchestration, and status toggles.""" - def __init__(self, client_storage: ClientStorage): - """ - Attributes - ---------- - _timetracking_intent : TimeTrackingIntent - reference to the TimeTrackingIntent for forwarding timetracking related intents - _data_source : InvoicingDataSource - reference to the invoicing data source - _projects_intent : ProjectsIntent - reference to the ProjectsIntent for forwarding project related intents - _auth_intent : AuthIntent - reference to the AuthIntent for forwarding auth related intents - _preferences_intent : PreferencesIntent - reference to the PreferencesIntent for reading user preferences - """ - self._client_storage = client_storage - self._timetracking_intent = TimeTrackingIntent(client_storage=client_storage) + def __init__(self, client_storage=None): self._projects_intent = ProjectsIntent() self._invoicing_data_source = InvoicingDataSource() self._timetracking_data_source = TimeTrackingDataFrameSource() self._user_data_source = UserDataSource() self._auth_intent = AuthIntent() - self._preferences_intent = PreferencesIntent(client_storage=client_storage) + self._preferences_intent = PreferencesIntent() + self._timetracking_intent = TimeTrackingIntent() + + # -- RPC-facing CRUD ------------------------------------------------------- + + def get_all(self) -> IntentResult: + return self._invoicing_data_source.get_all_invoices() + + def delete(self, id) -> IntentResult: + return self.delete_invoice_by_id(id) + + def create( + self, + project_id, + invoice_date, + from_date, + to_date, + render=True, + manual_quantity=None, + ) -> IntentResult: + """Orchestrates invoice creation: resolve project, read prefs, delegate.""" + proj_result = self._projects_intent.get_by_id(project_id) + if not proj_result.was_intent_successful: + return proj_result + app_db = AppDatabase() + language = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" + template_name = ( + app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) + or DEFAULT_INVOICE_TEMPLATE + ) + + def _to_date(v): + return v if isinstance(v, date) else _dt.date.fromisoformat(v) + + return self.create_invoice( + invoice_date=_to_date(invoice_date), + project=proj_result.data, + from_date=_to_date(from_date), + to_date=_to_date(to_date), + render=render, + manual_quantity=manual_quantity, + language=language, + template_name=template_name, + ) + + # -- Status toggles (accept id, fetch internally) -------------------------- + + def _toggle(self, field: str, invoice_id: int) -> IntentResult: + result = self._invoicing_data_source.get_invoice_by_id(invoice_id) + if not result.was_intent_successful or not result.data: + return IntentResult( + was_intent_successful=False, error_msg="Invoice not found" + ) + return getattr(self, f"toggle_invoice_{field}_status")(result.data) + + def toggle_sent(self, id) -> IntentResult: + return self._toggle("sent", id) + + def toggle_paid(self, id) -> IntentResult: + return self._toggle("paid", id) + + def toggle_cancelled(self, id) -> IntentResult: + return self._toggle("cancelled", id) + + # -- Static data ----------------------------------------------------------- + + def available_templates(self) -> IntentResult: + return IntentResult(was_intent_successful=True, data=INVOICE_TEMPLATES) + + def available_languages(self) -> IntentResult: + return IntentResult( + was_intent_successful=True, data=SUPPORTED_INVOICE_LANGUAGES + ) def get_user(self) -> IntentResult[User]: user = self._user_data_source.get_user() diff --git a/tuttle/app/llm/__init__.py b/tuttle/app/llm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/app/llm/intent.py b/tuttle/app/llm/intent.py new file mode 100644 index 00000000..2fe21145 --- /dev/null +++ b/tuttle/app/llm/intent.py @@ -0,0 +1,37 @@ +"""LLM configuration and document parsing.""" + +import tuttle.llm as _llm + +from ..core.intent_result import IntentResult + + +class LlmIntent: + def get_config(self) -> IntentResult: + return IntentResult( + was_intent_successful=True, + data=_llm.load_config(), + ) + + def save_config(self, config: dict) -> IntentResult: + saved = _llm.save_config(_llm.LLMConfig(**config)) + return IntentResult(was_intent_successful=True, data=saved) + + def get_models(self, base_url: str = "http://localhost:11434") -> IntentResult: + return IntentResult( + was_intent_successful=True, + data=_llm.get_available_models(base_url), + ) + + def parse_document( + self, + file_base64: str, + file_name: str, + entity_type: str = "contact", + ) -> IntentResult: + items = _llm.parse_document( + file_base64, + file_name, + entity_type, + _llm.load_config(), + ) + return IntentResult(was_intent_successful=True, data=items) diff --git a/tuttle/app/preferences/intent.py b/tuttle/app/preferences/intent.py index c95af822..311446a6 100644 --- a/tuttle/app/preferences/intent.py +++ b/tuttle/app/preferences/intent.py @@ -1,183 +1,64 @@ -from typing import Optional -from loguru import logger +"""Preferences backed by the app-level database. -from ..core.abstractions import ClientStorage, Intent -from ..core.intent_result import IntentResult - -from .model import Preferences, PreferencesStorageKeys - - -class PreferencesIntent(Intent): - """Handles Preferences intents - - Intents handled (Methods) - --------------- - - get_preferences_intent - fetching a Preferences object - - save_preferences_intent - storing a Preferences object +Replaces the legacy ClientStorage-based implementation. Both the +RPC-facing methods (``get`` / ``save``) and the internal API used by +other intents (``get_preference_by_key``, ``get_preferred_invoice_template``) +read from the same ``AppDatabase`` backend. +""" - get_preference_by_key_intent - reading a single preference value given it's key +from ..core.intent_result import IntentResult +from ...app_db import AppDatabase +from .model import PreferencesStorageKeys, DEFAULT_INVOICE_TEMPLATE - set_preference_key_value_pair_intent - storing a preference item given it's key and value - """ - def __init__( - self, - client_storage: ClientStorage, - ): - self._client_storage = client_storage +class PreferencesIntent: + def __init__(self, client_storage=None): + self._app_db = AppDatabase() - def get_preferences(self) -> IntentResult: - preferences = Preferences() - for item in PreferencesStorageKeys: - preference_item_result = self.get_preference_by_key(item) - if not preference_item_result.data: - continue - if not preference_item_result.was_intent_successful: - preference_item_result.log_message_if_any() - return IntentResult( - was_intent_successful=False, - error_msg="Loading preferences failed!", - ) - if item.value == PreferencesStorageKeys.theme_mode_key.value: - preferences.theme_mode = preference_item_result.data - elif item.value == PreferencesStorageKeys.cloud_acc_id_key.value: - preferences.cloud_acc_id = preference_item_result.data - elif item.value == PreferencesStorageKeys.cloud_provider_key.value: - preferences.cloud_acc_provider = preference_item_result.data - elif item.value == PreferencesStorageKeys.language_key.value: - preferences.language = preference_item_result.data - elif item.value == PreferencesStorageKeys.invoice_template_key.value: - preferences.invoice_template = preference_item_result.data + # -- RPC-facing ------------------------------------------------------------ + def get(self) -> IntentResult: return IntentResult( was_intent_successful=True, - data=preferences, + data={ + "invoice_template": self._app_db.get_setting( + PreferencesStorageKeys.invoice_template_key.value, + ) + or DEFAULT_INVOICE_TEMPLATE, + "language": self._app_db.get_setting( + PreferencesStorageKeys.language_key.value, + ) + or "en", + }, ) - def save_preferences(self, preferences: Preferences) -> IntentResult: - try: - self.set_preference_key_value_pair( - PreferencesStorageKeys.theme_mode_key, preferences.theme_mode - ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.cloud_acc_id_key, preferences.cloud_acc_id - ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.cloud_provider_key, - preferences.cloud_acc_provider, - ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.language_key, - preferences.language, - ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.invoice_template_key, - preferences.invoice_template, - ) - except Exception as e: - result = IntentResult( - was_intent_successful=False, - exception=e, - error_msg="Failed to save preferences", - log_message=f"An exception was raised @PreferencesIntent.save_preferences {e.__class__.__name__}", - ) - result.log_message_if_any() - return result - - def get_preference_by_key( - self, preference_key: PreferencesStorageKeys - ) -> IntentResult: - try: - preference = self._client_storage.get_value(preference_key.value) - return IntentResult(was_intent_successful=True, data=preference) - except Exception as e: - result = IntentResult( - was_intent_successful=False, - exception=e, - error_msg="Failed to load that preference item", - log_message=f"Exception was raised @PreferencesIntent.get_preference f{e.__class__.__name__}", + def save(self, invoice_template=None, language=None) -> IntentResult: + if invoice_template is not None: + self._app_db.set_setting( + PreferencesStorageKeys.invoice_template_key.value, + invoice_template, ) - result.log_message_if_any() - return result - - def set_preference_key_value_pair( - self, preference_key: PreferencesStorageKeys, value: any - ) -> IntentResult: - try: - self._client_storage.set_value( - key=preference_key.value, - value=value, - ) - return IntentResult(was_intent_successful=True, data=None) - except Exception as e: - result = IntentResult( - was_intent_successful=False, - exception=e, - error_msg="Saving preferences failed!", - log_message=f"Exception was raised @PreferencesIntent.set_preference f{e.__class__.__name__}", + if language is not None: + self._app_db.set_setting( + PreferencesStorageKeys.language_key.value, + language, ) - result.log_message_if_any() - return result + return IntentResult(was_intent_successful=True, data=None) - def get_preferred_theme(self) -> IntentResult[Optional[str]]: - """Returns the preferred theme mode as string""" - result: IntentResult = self.get_preference_by_key( - preference_key=PreferencesStorageKeys.theme_mode_key - ) - if not result.was_intent_successful: - result.error_msg = "Failed to load your preferred theme" - result.log_message_if_any() - return result + # -- Internal API (used by other intents) ---------------------------------- - def get_preferred_invoice_template(self) -> IntentResult[Optional[str]]: - """Returns the preferred invoice template name as string""" - result: IntentResult = self.get_preference_by_key( - preference_key=PreferencesStorageKeys.invoice_template_key + def get_preference_by_key(self, key) -> IntentResult: + k = key.value if hasattr(key, "value") else key + return IntentResult( + was_intent_successful=True, + data=self._app_db.get_setting(k), ) - if not result.was_intent_successful: - result.error_msg = "Failed to load your preferred invoice template" - result.log_message_if_any() - return result - def clear_preferences(self) -> IntentResult[None]: - """Clears all preferences""" - try: - self._client_storage.clear_preferences() - return IntentResult(was_intent_successful=True) - except Exception as ex: - logger.error(f"Failed to clear preferences: {ex.__class__.__name__}") - logger.exception(ex) - result = IntentResult( - was_intent_successful=False, - exception=ex, - error_msg=f"Failed to clear preferences: {ex.__class__.__name__}", + def get_preferred_invoice_template(self) -> IntentResult: + tmpl = ( + self._app_db.get_setting( + PreferencesStorageKeys.invoice_template_key.value, ) - result.log_message_if_any() - return result - - def reset_app(self) -> IntentResult: - """Resets the app to it's default state""" - try: - logger.info("Resetting the app to default state") - logger.info("Clearing all preferences") - self._client_storage.clear_preferences() - logger.info("Clearing all data") - self._page.window.close() - - return IntentResult( - was_intent_successful=True, - ) - except Exception as ex: - result = IntentResult( - was_intent_successful=False, - exception=ex, - error_msg="Failed to reset app", - ) - result.log_message_if_any() - return result + or DEFAULT_INVOICE_TEMPLATE + ) + return IntentResult(was_intent_successful=True, data=tmpl) diff --git a/tuttle/app/projects/intent.py b/tuttle/app/projects/intent.py index c29e81a3..a89242fc 100644 --- a/tuttle/app/projects/intent.py +++ b/tuttle/app/projects/intent.py @@ -16,6 +16,7 @@ class ProjectsIntent(CrudIntent): ("invoices", "invoices", lambda i: i.number or f"#{i.id}"), ("timesheets", "timesheets", lambda t: t.title), ] + __save_skip__ = {"contract", "timesheets", "invoices"} def __init__(self): super().__init__() @@ -32,8 +33,7 @@ def get_all_contracts_as_map(self): # -- Project-specific logic ------------------------------------------------ - def save_project(self, project: Project) -> IntentResult[Optional[Project]]: - """Validate and save a project.""" + def _validated_save(self, project: Project) -> IntentResult[Optional[Project]]: is_updating = project.id is not None result = self.save(project) if not result.was_intent_successful: @@ -44,8 +44,4 @@ def save_project(self, project: Project) -> IntentResult[Optional[Project]]: result.log_message_if_any() return result - def toggle_project_completed_status( - self, project: Project - ) -> IntentResult[Project]: - """Updates the project completed status.""" - return self.toggle_completed(project) + toggle_project_completed_status = CrudIntent.toggle_completed diff --git a/tuttle/app/salary/intent.py b/tuttle/app/salary/intent.py index 3c66856d..d00872f8 100644 --- a/tuttle/app/salary/intent.py +++ b/tuttle/app/salary/intent.py @@ -72,6 +72,21 @@ def save_expense(self, expense: RecurringExpense) -> IntentResult: """Persist a new or updated recurring expense.""" return self._data_source.save_expense(expense) + def save_expense_from_dict(self, data: dict) -> IntentResult: + """Create or update a recurring expense from a plain dict.""" + expense_id = data.get("id") + if expense_id: + result = self.get_expenses() + if result.was_intent_successful and result.data: + existing = next((e for e in result.data if e.id == expense_id), None) + if existing: + for k, v in data.items(): + if k != "id" and not k.startswith("_"): + setattr(existing, k, v) + return self.save_expense(existing) + clean = {k: v for k, v in data.items() if k != "id" and not k.startswith("_")} + return self.save_expense(RecurringExpense(**clean)) + def delete_expense(self, expense_id: int) -> IntentResult: """Remove a recurring expense by id.""" return self._data_source.delete_expense_by_id(expense_id) diff --git a/tuttle/app/settings/__init__.py b/tuttle/app/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/app/settings/intent.py b/tuttle/app/settings/intent.py new file mode 100644 index 00000000..fe09f3a1 --- /dev/null +++ b/tuttle/app/settings/intent.py @@ -0,0 +1,25 @@ +"""App-level key/value settings backed by AppDatabase.""" + +from ..core.intent_result import IntentResult +from ...app_db import AppDatabase + + +class SettingsIntent: + def __init__(self): + self._app_db = AppDatabase() + + def get(self, key: str) -> IntentResult: + return IntentResult( + was_intent_successful=True, + data=self._app_db.get_setting(key), + ) + + def set(self, key: str, value: str) -> IntentResult: + self._app_db.set_setting(key, value) + return IntentResult(was_intent_successful=True, data=None) + + def get_all(self, prefix: str = None) -> IntentResult: + return IntentResult( + was_intent_successful=True, + data=self._app_db.get_all_settings(prefix=prefix), + ) diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py index 07e05c44..5c1e9214 100644 --- a/tuttle/app/tax/intent.py +++ b/tuttle/app/tax/intent.py @@ -1,12 +1,13 @@ """Business logic for the Tax view.""" +import datetime from decimal import Decimal from ..core.abstractions import SQLModelDataSourceMixin, Intent from ..core.intent_result import IntentResult from ...model import Invoice, User -from ...tax import get_tax_system +from ...tax import get_tax_system, supported_countries from ...tax_reserves import ( compute_spendable_income, compute_income_tax_reserve, @@ -57,8 +58,6 @@ def get_spendable_income(self) -> IntentResult: def get_income_tax_estimate(self) -> IntentResult: """Get detailed income tax estimate with bracket info.""" try: - import datetime as _dt - invoices = self.query(Invoice) country = self._get_country() currency = self._get_tax_currency(country) @@ -66,7 +65,11 @@ def get_income_tax_estimate(self) -> IntentResult: tax_reserve = compute_income_tax_reserve(spending.net_revenue_ytd, country) days_elapsed = max( - (_dt.date.today() - _dt.date.today().replace(month=1, day=1)).days, 1 + ( + datetime.date.today() + - datetime.date.today().replace(month=1, day=1) + ).days, + 1, ) annualized = float(spending.net_revenue_ytd) * 365 / days_elapsed @@ -131,6 +134,10 @@ def _compute_bracket_data(self, tax_system, annualized_income: Decimal) -> list: prev_end = end return brackets + def supported_countries(self) -> IntentResult: + """Return list of countries with tax system support.""" + return IntentResult(was_intent_successful=True, data=supported_countries()) + def get_quarterly_vat(self, year: int | None = None) -> IntentResult: """Get quarterly VAT breakdown.""" try: diff --git a/tuttle/app/timeline/intent.py b/tuttle/app/timeline/intent.py index 9a1da7dd..b7122bf3 100644 --- a/tuttle/app/timeline/intent.py +++ b/tuttle/app/timeline/intent.py @@ -77,7 +77,7 @@ def __init__(self): # ── Public API ──────────────────────────────────────────── - def get_timeline_events( + def get_events( self, category_filter: Optional[str] = None, ) -> IntentResult: @@ -104,7 +104,7 @@ def get_timeline_events( return IntentResult( was_intent_successful=False, error_msg="Failed to load timeline events.", - log_message=f"TimelineIntent.get_timeline_events: {e}", + log_message=f"TimelineIntent.get_events: {e}", exception=e, ) diff --git a/tuttle/app/timetracking/aggregation.py b/tuttle/app/timetracking/aggregation.py new file mode 100644 index 00000000..968ec6ae --- /dev/null +++ b/tuttle/app/timetracking/aggregation.py @@ -0,0 +1,150 @@ +"""Time-tracking data aggregation and serialization. + +Converts pandas DataFrames (the in-memory time-tracking store) into +JSON-safe dicts suitable for the frontend calendar view, event lists, +and summary panels. +""" + +import calendar as cal_mod +import datetime +from typing import Optional + +from pandas import DataFrame + + +def df_to_records(df: DataFrame) -> list: + """Convert a time-tracking DataFrame to a list of JSON-safe dicts.""" + if df is None or df.empty: + return [] + records = [] + for idx, row in df.iterrows(): + begin = idx + if hasattr(begin, "isoformat"): + begin = begin.isoformat() + end = row.get("end") + if hasattr(end, "isoformat"): + end = end.isoformat() + dur = row.get("duration") + dur_hours = dur.total_seconds() / 3600 if hasattr(dur, "total_seconds") else 0 + records.append( + { + "begin": str(begin), + "end": str(end) if end is not None else None, + "duration_hours": round(dur_hours, 2), + "title": str(row.get("title", "")), + "tag": str(row.get("tag", "")), + "description": str(row.get("description", "") or ""), + "all_day": bool(row.get("all_day", False)), + "date": str(begin)[:10], + } + ) + return records + + +def build_calendar_data( + df: DataFrame, + year: int, + month: int, + project_tag: Optional[str] = None, +) -> dict: + """Build a month-view calendar payload from a time-tracking DataFrame. + + Returns a dict with ``events``, ``projects`` (unique tags with hours), + ``days`` (per-day aggregation), and ``summary`` (totals). + """ + start = datetime.date(year, month, 1) + _, last_day = cal_mod.monthrange(year, month) + end = datetime.date(year, month, last_day) + + mask = (df.index.date >= start) & (df.index.date <= end) + month_df = df[mask] + if project_tag: + month_df = month_df[month_df["tag"] == project_tag] + + events = df_to_records(month_df) + + by_tag = ( + month_df.groupby("tag")["duration"] + .sum() + .apply(lambda td: round(td.total_seconds() / 3600, 1)) + .to_dict() + ) + projects = [ + {"tag": t, "hours": h} for t, h in sorted(by_tag.items(), key=lambda x: -x[1]) + ] + + days: dict = {} + for idx, row in month_df.iterrows(): + d = str(idx.date()) if hasattr(idx, "date") else str(idx)[:10] + if d not in days: + days[d] = {"date": d, "hours": 0.0, "tags": [], "count": 0} + dur = row.get("duration") + h = dur.total_seconds() / 3600 if hasattr(dur, "total_seconds") else 0 + days[d]["hours"] = round(days[d]["hours"] + h, 2) + days[d]["count"] += 1 + tag = str(row.get("tag", "")) + if tag and tag not in days[d]["tags"]: + days[d]["tags"].append(tag) + + total_hours = ( + round(month_df["duration"].sum().total_seconds() / 3600, 1) + if len(month_df) + else 0 + ) + + return { + "year": year, + "month": month, + "first_weekday": start.weekday(), + "days_in_month": last_day, + "events": events, + "projects": projects, + "days": days, + "summary": { + "total_events": len(month_df), + "total_hours": total_hours, + }, + } + + +def build_summary(df: DataFrame, tag_to_title: dict) -> dict: + """Build a time-tracking summary: totals and per-project breakdown. + + *tag_to_title* maps project tags to human-readable titles. + """ + if df is None or df.empty: + return {"total_events": 0, "total_hours": 0, "projects": []} + + total_hours = df["duration"].sum().total_seconds() / 3600 + by_tag = ( + df.groupby("tag")["duration"] + .sum() + .apply(lambda td: round(td.total_seconds() / 3600, 1)) + .to_dict() + ) + project_summaries = [] + for tag, hours in sorted(by_tag.items(), key=lambda x: -x[1]): + project_summaries.append( + { + "tag": tag, + "title": tag_to_title.get(tag, tag), + "hours": hours, + "event_count": int((df["tag"] == tag).sum()), + } + ) + return { + "total_events": len(df), + "total_hours": round(total_hours, 1), + "projects": project_summaries, + } + + +def merge_dataframes(existing: Optional[DataFrame], new_df: DataFrame) -> DataFrame: + """Merge *new_df* into *existing*, deduplicating by index.""" + if existing is not None and not existing.empty: + import pandas + + combined = pandas.concat([existing, new_df]) + combined = combined[~combined.index.duplicated(keep="last")] + return combined + return new_df diff --git a/tuttle/app/timetracking/intent.py b/tuttle/app/timetracking/intent.py index 81153154..2729ba44 100644 --- a/tuttle/app/timetracking/intent.py +++ b/tuttle/app/timetracking/intent.py @@ -1,36 +1,195 @@ -from typing import Optional, Type, Union - +import base64 +import datetime from pathlib import Path +from typing import Optional, Type, Union from loguru import logger +from pandas import DataFrame - -from ..core.abstractions import ClientStorage, Intent +from ..core.abstractions import Intent from ..core.intent_result import IntentResult -from pandas import DataFrame from ..preferences.intent import PreferencesIntent from ..preferences.model import PreferencesStorageKeys +from ..projects.intent import ProjectsIntent +from ...calendar import Calendar, ICSCalendar +from ...cloud import CloudConnector, CloudProvider +from ...eventkit_bridge import ( + fetch_events, + is_available, + list_calendars_with_status, + open_calendar_privacy_settings, +) +from .aggregation import ( + build_calendar_data, + build_summary, + df_to_records, + merge_dataframes, +) from .data_source import ( TimeTrackingCloudCalendarSource, TimeTrackingDataFrameSource, TimeTrackingFileCalendarSource, TimeTrackingSpreadsheetSource, ) -from ...cloud import CloudConnector, CloudProvider -from ...calendar import Calendar class TimeTrackingIntent(Intent): - """Handles time tracking intents""" - - def __init__(self, client_storage: ClientStorage): + """Time-tracking data access, import, aggregation.""" + def __init__(self, client_storage=None): self._cloud_calendar_source = TimeTrackingCloudCalendarSource() self._file_calendar_source = TimeTrackingFileCalendarSource() self._spreadsheet_source = TimeTrackingSpreadsheetSource() self._timetracking_data_frame_source = TimeTrackingDataFrameSource() - self._preferences_intent = PreferencesIntent(client_storage) + self._preferences_intent = PreferencesIntent() + + # -- RPC-facing methods ---------------------------------------------------- + + def get_events(self, project_tag=None) -> IntentResult: + df = self._timetracking_data_frame_source.get_data_frame() + if df is None or df.empty: + return IntentResult(was_intent_successful=True, data=[]) + if project_tag: + df = df[df["tag"] == project_tag] + return IntentResult(was_intent_successful=True, data=df_to_records(df)) + + def get_calendar_data( + self, year=None, month=None, project_tag=None + ) -> IntentResult: + df = self._timetracking_data_frame_source.get_data_frame() + if df is None or df.empty: + return IntentResult( + was_intent_successful=True, + data={ + "events": [], + "projects": [], + "summary": {}, + }, + ) + if year is None: + year = datetime.date.today().year + if month is None: + month = datetime.date.today().month + return IntentResult( + was_intent_successful=True, + data=build_calendar_data(df, year, month, project_tag), + ) + + def import_ics(self, content: str, name: str = "imported.ics") -> IntentResult: + raw = base64.b64decode(content) + cal = ICSCalendar(name=name, content=raw) + new_df = cal.to_data() + ds = self._timetracking_data_frame_source + ds.store_data_frame(merge_dataframes(ds.get_data_frame(), new_df)) + records = df_to_records(new_df) + return IntentResult( + was_intent_successful=True, + data={"imported_count": len(records), "events": records}, + ) + + def clear(self) -> IntentResult: + self._timetracking_data_frame_source.store_data_frame(None) + return IntentResult(was_intent_successful=True, data=None) + + def list_system_calendars(self, open_settings=False) -> IntentResult: + if not is_available(): + return IntentResult( + was_intent_successful=True, + data={ + "calendars": [], + "auth_status": "not_available", + }, + ) + if open_settings: + open_calendar_privacy_settings() + return IntentResult( + was_intent_successful=True, + data={ + "calendars": [], + "auth_status": "pending", + }, + ) + try: + return IntentResult( + was_intent_successful=True, + data=list_calendars_with_status(), + ) + except Exception as ex: + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + data={"calendars": [], "auth_status": "unknown"}, + error_msg=str(ex), + ) + + def import_system_calendar( + self, + calendar_id, + from_date=None, + to_date=None, + ) -> IntentResult: + if not is_available(): + return IntentResult( + was_intent_successful=False, + error_msg="System calendar access is only available on macOS", + ) + if from_date is None: + from_date = datetime.date.today() - datetime.timedelta(days=365) + elif isinstance(from_date, str): + from_date = datetime.date.fromisoformat(from_date) + if to_date is None: + to_date = datetime.date.today() + elif isinstance(to_date, str): + to_date = datetime.date.fromisoformat(to_date) + try: + new_df = fetch_events(calendar_id, from_date, to_date) + if new_df.empty: + return IntentResult( + was_intent_successful=True, + data={ + "imported_count": 0, + "events": [], + }, + ) + ds = self._timetracking_data_frame_source + ds.store_data_frame(merge_dataframes(ds.get_data_frame(), new_df)) + records = df_to_records(new_df) + return IntentResult( + was_intent_successful=True, + data={ + "imported_count": len(records), + "events": records, + }, + ) + except Exception as ex: + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg=str(ex), + ) + + def get_summary(self) -> IntentResult: + df = self._timetracking_data_frame_source.get_data_frame() + if df is None or df.empty: + return IntentResult( + was_intent_successful=True, + data={ + "total_events": 0, + "total_hours": 0, + "projects": [], + }, + ) + proj_result = ProjectsIntent().get_all() + tag_to_title = {} + if proj_result.was_intent_successful and proj_result.data: + tag_to_title = {p.tag: p.title for p in proj_result.data} + return IntentResult( + was_intent_successful=True, + data=build_summary(df, tag_to_title), + ) + + # -- Legacy internal methods ----------------------------------------------- def get_preferred_cloud_account(self) -> IntentResult[Optional[list]]: """ diff --git a/tuttle/app/users/__init__.py b/tuttle/app/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/app/users/intent.py b/tuttle/app/users/intent.py new file mode 100644 index 00000000..27cc259e --- /dev/null +++ b/tuttle/app/users/intent.py @@ -0,0 +1,260 @@ +"""User management: registration, profile updates, demo provisioning.""" + +from pathlib import Path +from typing import Any, Dict, Optional + +from loguru import logger +from sqlmodel import Session as SqlSession, create_engine as sql_create_engine + +from ...app_db import AppDatabase +from ...model import Address, User +from ..auth.data_source import UserDataSource +from ..core.abstractions import get_active_db, set_active_db +from ..core.intent_result import IntentResult +from ..core.rpc_utils import reset_all +from ...migrations.run import run_migrations + + +class UsersIntent: + """Manages the user registry (app.db) and per-user profile (user.db).""" + + def __init__(self): + self._app_db = AppDatabase() + + # -- helpers --------------------------------------------------------------- + + def _ensure_user_db(self, db_path: Path): + run_migrations(f"sqlite:///{db_path}") + + def _switch_to_user_db(self, db_file: str): + """Switch the active per-user database and flush intent caches.""" + db_path = self._app_db.get_user_db_path(db_file) + self._ensure_user_db(db_path) + set_active_db(db_path) + self._app_db.set_active(db_file) + reset_all() + logger.info(f"Switched to user DB: {db_file}") + + if db_file == "harry-tuttle.db": + self._ensure_demo_timetracking(db_path) + + def _ensure_demo_timetracking(self, db_path: Path = None): + """Repopulate demo time-tracking data for the Harry Tuttle demo user.""" + from ..timetracking.data_source import TimeTrackingDataFrameSource + + ds = TimeTrackingDataFrameSource() + if ds.get_data_frame() is not None: + return + try: + from sqlmodel import Session, create_engine, select + from ...model import Project + from ...demo import create_fake_calendar + from ...calendar import ICSCalendar + + if db_path is None: + db_path = get_active_db() + + engine = create_engine(f"sqlite:///{db_path}") + with Session(engine) as session: + projects = session.exec(select(Project)).all() + if not projects: + return + cal = ICSCalendar( + name="Demo calendar", + ics_calendar=create_fake_calendar(list(projects)), + ) + df = cal.to_data() + ds.store_data_frame(df) + logger.info(f"Repopulated {len(df)} demo time-tracking events") + except Exception as ex: + logger.warning(f"Could not repopulate demo timetracking: {ex}") + + # -- user list / switch / delete ------------------------------------------ + + def list(self) -> IntentResult: + users = self._app_db.list_users() + return IntentResult(was_intent_successful=True, data=users) + + list_users = list + + def switch(self, db_file: str, **_kw) -> IntentResult: + self._switch_to_user_db(db_file) + return IntentResult(was_intent_successful=True, data=None) + + def delete(self, db_file: str, **_kw) -> IntentResult: + removed = self._app_db.remove_user(db_file) + if removed: + reset_all() + return IntentResult(was_intent_successful=True, data=removed) + + def get_active(self) -> IntentResult: + """Return the active registered user with their profile. + + Returns a flat dict with registration fields at the top level + and the user profile nested under ``profile``. + """ + active_path = get_active_db() + active_file = active_path.name + reg = self._app_db.get_user_by_db_file(active_file) + if not reg: + return IntentResult(was_intent_successful=True, data=None) + try: + ds = UserDataSource() + profile = ds.get_user() + except Exception: + profile = None + data = reg.model_dump() + data["profile"] = profile + return IntentResult(was_intent_successful=True, data=data) + + # -- create --------------------------------------------------------------- + + def create(self, params: Dict[str, Any], **_kw) -> IntentResult: + """Register a new user and initialise their database.""" + name = params["name"] + subtitle = params.get("subtitle", "") + reg = self._app_db.add_user(name=name, subtitle=subtitle) + db_path = self._app_db.get_user_db_path(reg.db_file) + run_migrations(f"sqlite:///{db_path}") + + engine = sql_create_engine(f"sqlite:///{db_path}") + with SqlSession(engine) as s: + address = Address( + street=params.get("street", ""), + number=params.get("street_num", ""), + postal_code=params.get("postal_code", ""), + city=params.get("city", ""), + country=params.get("country", ""), + ) + user = User( + name=name, + subtitle=subtitle, + email=params.get("email", ""), + phone_number=params.get("phone", ""), + website=params.get("website", ""), + operating_country=params.get("operating_country", "Germany"), + VAT_number=params.get("vat_number", ""), + address=address, + ) + s.add(user) + s.commit() + engine.dispose() + + self._switch_to_user_db(reg.db_file) + return IntentResult(was_intent_successful=True, data=reg) + + # -- profile update ------------------------------------------------------- + + def update_profile(self, profile_data: Dict[str, Any]) -> IntentResult: + """Update the active user's profile from a dict.""" + ds = UserDataSource() + profile = ds.get_user() + if not profile: + return IntentResult( + was_intent_successful=False, + error_msg="No user profile found", + ) + + for k in ( + "name", + "subtitle", + "email", + "phone_number", + "website", + "VAT_number", + "operating_country", + ): + if k in profile_data: + setattr(profile, k, profile_data[k]) + + addr = profile_data.get("address") + if addr: + if profile.address: + for k in ("street", "number", "postal_code", "city", "country"): + if k in addr: + setattr(profile.address, k, addr[k]) + else: + profile.address = Address( + **{ + k: v + for k, v in addr.items() + if k != "id" and not k.startswith("_") + } + ) + + with ds.create_session() as s: + s.add(profile) + s.commit() + s.refresh(profile) + + # Sync name/subtitle back to the app.db registration record. + active_file = get_active_db().name + reg = self._app_db.get_user_by_db_file(active_file) + if reg and ( + reg.name != profile.name or reg.subtitle != (profile.subtitle or "") + ): + with self._app_db._session() as s: + db_reg = s.get(type(reg), reg.id) + if db_reg: + db_reg.name = profile.name + db_reg.subtitle = profile.subtitle or "" + s.add(db_reg) + s.commit() + + return IntentResult(was_intent_successful=True, data=profile) + + # -- demo ----------------------------------------------------------------- + + def ensure_demo( + self, + invoice_language: str = "en", + invoice_template: str = "invoice-modern", + ) -> IntentResult: + """Ensure the Harry Tuttle demo user exists.""" + from ...demo import install_demo_data + from ..timetracking.data_source import TimeTrackingDataFrameSource + + if self._app_db.get_user_by_db_file("harry-tuttle.db"): + reg = self._app_db.get_user_by_db_file("harry-tuttle.db") + return IntentResult(was_intent_successful=True, data=reg) + + reg = self._app_db.add_user( + name="Harry Tuttle", + subtitle="Heating Engineer", + is_demo=True, + db_file="harry-tuttle.db", + ) + db_path = self._app_db.get_user_db_path(reg.db_file) + if db_path.exists(): + db_path.unlink() + run_migrations(f"sqlite:///{db_path}") + + def _cache_demo_timetracking(df): + ds = TimeTrackingDataFrameSource() + ds.store_data_frame(df) + logger.info(f"Cached {len(df)} demo time-tracking events") + + install_demo_data( + n_projects=4, + db_path=str(db_path), + on_cache_timetracking_dataframe=_cache_demo_timetracking, + invoice_language=invoice_language, + invoice_template=invoice_template, + ) + logger.info("Demo user Harry Tuttle created with heating-repair data") + return IntentResult(was_intent_successful=True, data=reg) + + def ensure_db(self, **_kw) -> IntentResult: + """Ensure app.db + demo user + last-active user DB exist and are migrated.""" + self._app_db.ensure() + self._app_db.migrate_llm_config_from_json() + self.ensure_demo() + + last = self._app_db.get_last_active() + if last: + self._switch_to_user_db(last.db_file) + else: + users = self._app_db.list_users() + if users: + self._switch_to_user_db(users[0].db_file) + return IntentResult(was_intent_successful=True, data=None) diff --git a/tuttle/demo.py b/tuttle/demo.py index c2bed3a4..9a7fd76c 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -652,3 +652,5 @@ def install_demo_data( for goal in goals: session.add(goal) session.commit() + + db_engine.dispose() diff --git a/tuttle/kpi.py b/tuttle/kpi.py index 925d5b75..11d8322e 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -8,6 +8,8 @@ from .tax import get_tax_system from .tax_reserves import compute_spendable_income +from .app.core.formatting import fmt_currency + class KPISummary(NamedTuple): """Snapshot of key business metrics.""" @@ -28,6 +30,27 @@ class KPISummary(NamedTuple): spendable_income: Decimal tax_currency: str = "EUR" + def to_rpc_dict(self) -> dict: + d = self._asdict() + tc = self.tax_currency or "EUR" + d["total_revenue_ytd_formatted"] = fmt_currency(self.total_revenue_ytd, tc) + d["outstanding_amount_formatted"] = fmt_currency(self.outstanding_amount, tc) + d["overdue_amount_formatted"] = fmt_currency(self.overdue_amount, tc) + d["vat_reserve_formatted"] = fmt_currency(self.vat_reserve, tc) + d["income_tax_reserve_formatted"] = fmt_currency(self.income_tax_reserve, tc) + d["spendable_income_formatted"] = fmt_currency(self.spendable_income, tc) + d["effective_hourly_rate_formatted"] = ( + fmt_currency(self.effective_hourly_rate, tc) + if self.effective_hourly_rate is not None + else "—" + ) + d["utilization_rate_formatted"] = ( + f"{self.utilization_rate * 100:.0f}%" + if self.utilization_rate is not None + else "—" + ) + return d + def compute_kpis( invoices: List[Invoice], diff --git a/tuttle/model.py b/tuttle/model.py index 092335a3..28aeba41 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -25,10 +25,55 @@ from sqlmodel import SQLModel, Field, Relationship, Constraint +from pathlib import Path + +from .app.core.formatting import fmt_currency from .dev import deprecated from .time import Cycle, TimeUnit +class RpcMixin: + """Mixin that auto-serialises SQLModel objects for RPC transport. + + Class-level declarations:: + + __rpc_relationships__ = ("address",) # full dump + __rpc_relationships__ = {"projects": ("id", "title")} # field projection + __rpc_computed__ = ("sum", "total", "status") # @property values + """ + + __rpc_relationships__: tuple | dict = () + __rpc_computed__: tuple = () + + def to_rpc_dict(self, _depth: int = 2) -> dict: + d = self.model_dump() + for prop in self.__rpc_computed__: + d[prop] = getattr(self, prop, None) + if _depth <= 0: + return d + rels = self.__rpc_relationships__ + items = rels.items() if isinstance(rels, dict) else ((r, None) for r in rels) + for rel_name, projection in items: + value = getattr(self, rel_name, None) + if value is None: + continue + if isinstance(value, list): + d[rel_name] = [_project(v, projection, _depth - 1) for v in value] + else: + d[rel_name] = _project(value, projection, _depth - 1) + return d + + +def _project(obj, projection, depth: int): + if projection: + return {f: getattr(obj, f, None) for f in projection} + if hasattr(obj, "to_rpc_dict"): + return obj.to_rpc_dict(_depth=depth) + if hasattr(obj, "model_dump"): + return obj.model_dump() + return obj + + def help(model_class: Type[BaseModel]): return pandas.DataFrame( ( @@ -59,11 +104,10 @@ def OneToOneRelationship(back_populates): ) -class Address(SQLModel, table=True): +class Address(RpcMixin, SQLModel, table=True): """Postal address.""" id: Optional[int] = Field(default=None, primary_key=True) - # name: str street: str = Field(default="") number: str = Field(default="") city: str = Field(default="") @@ -104,9 +148,11 @@ def is_empty(self) -> bool: ) -class User(SQLModel, table=True): +class User(RpcMixin, SQLModel, table=True): """User of the application, a freelancer.""" + __rpc_relationships__ = ("address",) + id: Optional[int] = Field(default=None, primary_key=True) name: str subtitle: str = Field( @@ -197,9 +243,11 @@ class BankAccount(SQLModel, table=True): user: User = Relationship(back_populates="bank_account") -class Contact(SQLModel, table=True): +class Contact(RpcMixin, SQLModel, table=True): """An entry in the address book.""" + __rpc_relationships__ = ("address",) + id: Optional[int] = Field(default=None, primary_key=True) first_name: Optional[str] last_name: Optional[str] @@ -260,9 +308,11 @@ def print_address(self, address_only: bool = False): ) -class Client(SQLModel, table=True): +class Client(RpcMixin, SQLModel, table=True): """A client the freelancer has contracted with.""" + __rpc_relationships__ = ("invoicing_contact",) + id: Optional[int] = Field(default=None, primary_key=True) name: str = Field( description="Name of the client.", @@ -279,15 +329,20 @@ class Client(SQLModel, table=True): back_populates="client", sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) - # non-invoice related contact person? CONTRACT_DEFAULT_VAT_RATE = 0.19 -class Contract(SQLModel, table=True): +class Contract(RpcMixin, SQLModel, table=True): """A contract defines the business conditions of a project""" + __rpc_relationships__ = { + "client": None, + "projects": ("id", "title"), + "invoices": ("id",), + } + id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(description="Short description of the contract.") client: Client = Relationship( @@ -348,7 +403,6 @@ class Contract(SQLModel, table=True): back_populates="contract", sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) - # TODO: model contractual promises like "at least 2 days per week" @property def volume_as_time(self): @@ -382,9 +436,11 @@ def get_status(self, default: str = "All") -> str: return default -class Project(SQLModel, table=True): +class Project(RpcMixin, SQLModel, table=True): """A project is a group of contract work for a client.""" + __rpc_relationships__ = ("contract",) + id: Optional[int] = Field(default=None, primary_key=True) title: str = Field( description="A short, unique title", @@ -567,9 +623,23 @@ def empty(self) -> bool: return len(self.items) == 0 -class Invoice(SQLModel, table=True): +class Invoice(RpcMixin, SQLModel, table=True): """An invoice is a bill for a client.""" + __rpc_relationships__ = ("contract", "project", "items") + __rpc_computed__ = ( + "sum", + "VAT_total", + "total", + "due_date", + "status", + "file_name", + "sum_formatted", + "vat_total_formatted", + "total_formatted", + "pdf_path", + ) + id: Optional[int] = Field(default=None, primary_key=True) number: Optional[str] = Field(description="The invoice number. Auto-generated.") # date and time @@ -628,7 +698,6 @@ class Invoice(SQLModel, table=True): def __repr__(self): return f"Invoice(id={self.id}, number={self.number}, date={self.date})" - # @property def sum(self) -> Decimal: """Sum over all invoice items.""" @@ -687,8 +756,32 @@ def file_name(self): """A string that can be used as a file name.""" return f"{self.prefix}.pdf" + @property + def sum_formatted(self) -> str: + currency = self.contract.currency if self.contract else "EUR" + return fmt_currency(self.sum, currency) + + @property + def vat_total_formatted(self) -> str: + currency = self.contract.currency if self.contract else "EUR" + return fmt_currency(self.VAT_total, currency) + + @property + def total_formatted(self) -> str: + currency = self.contract.currency if self.contract else "EUR" + return fmt_currency(self.total, currency) + + @property + def pdf_path(self) -> Optional[str]: + if not self.rendered: + return None + p = Path.home() / ".tuttle" / "Invoices" / self.file_name + return str(p) if p.exists() else None + + +class InvoiceItem(RpcMixin, SQLModel, table=True): + __rpc_computed__ = ("subtotal", "subtotal_formatted", "unit_price_formatted") -class InvoiceItem(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) # date and time start_date: datetime.date = Field(description="Start date of the invoice item.") @@ -714,6 +807,14 @@ class InvoiceItem(SQLModel, table=True): def subtotal(self) -> Decimal: return Decimal(str(self.quantity)) * self.unit_price + @property + def subtotal_formatted(self) -> str: + return fmt_currency(self.subtotal) + + @property + def unit_price_formatted(self) -> str: + return fmt_currency(self.unit_price) + @property def VAT(self) -> Decimal: """VAT for the invoice item.""" diff --git a/tuttle/rpc_server.py b/tuttle/rpc_server.py index 058c35ee..82f4cd03 100644 --- a/tuttle/rpc_server.py +++ b/tuttle/rpc_server.py @@ -1,1412 +1,31 @@ -"""JSON-RPC 2.0 server over stdio. +"""JSON-RPC 2.0 stdin/stdout transport. -Bridges the existing intent layer to any external process (Electron, CLI, etc.) -by reading newline-delimited JSON-RPC requests from stdin and writing responses -to stdout. Each request is dispatched to the appropriate intent method, and -SQLModel results are serialised via ``model_dump()``. +This module does exactly two things: + 1. Read newline-delimited JSON-RPC requests from stdin. + 2. Route them through ``tuttle.app.core.dispatch.dispatch`` and write + the JSON-RPC response to stdout. + +All domain logic, serialisation, and intent lifecycle live elsewhere. Usage:: python -m tuttle.rpc_server """ -import datetime import json import sys import traceback -from decimal import Decimal -from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict from loguru import logger -# Redirect loguru to stderr so it never pollutes the JSON-RPC stdout channel. logger.remove() logger.add(sys.stderr, level="DEBUG") -# --------------------------------------------------------------------------- -# Lazy intent singletons -# --------------------------------------------------------------------------- - -_intents: Dict[str, Any] = {} - - -def _get_intent(name: str): - if name not in _intents: - if name == "contacts": - from tuttle.app.contacts.intent import ContactsIntent - - _intents[name] = ContactsIntent() - elif name == "clients": - from tuttle.app.clients.intent import ClientsIntent - - _intents[name] = ClientsIntent() - elif name == "contracts": - from tuttle.app.contracts.intent import ContractsIntent - - _intents[name] = ContractsIntent() - elif name == "projects": - from tuttle.app.projects.intent import ProjectsIntent - - _intents[name] = ProjectsIntent() - elif name == "invoicing": - from tuttle.app.invoicing.intent import InvoicingIntent - - _intents[name] = InvoicingIntent(client_storage=None) - elif name == "invoicing_ds": - from tuttle.app.invoicing.data_source import InvoicingDataSource - - _intents[name] = InvoicingDataSource() - elif name == "dashboard": - from tuttle.app.dashboard.intent import DashboardIntent - - _intents[name] = DashboardIntent() - elif name == "timeline": - from tuttle.app.timeline.intent import TimelineIntent - - _intents[name] = TimelineIntent() - elif name == "tax": - from tuttle.app.tax.intent import TaxIntent - - _intents[name] = TaxIntent() - elif name == "salary": - from tuttle.app.salary.intent import SalaryIntent - - _intents[name] = SalaryIntent() - elif name == "timetracking": - from tuttle.app.timetracking.intent import TimeTrackingIntent - - _intents[name] = TimeTrackingIntent(client_storage=None) - elif name == "timetracking_ds": - from tuttle.app.timetracking.data_source import TimeTrackingDataFrameSource - - _intents[name] = TimeTrackingDataFrameSource() - else: - raise ValueError(f"Unknown intent domain: {name}") - return _intents[name] - - -def _reset_intents(): - """Re-create all intent singletons (e.g. after demo data install).""" - _intents.clear() - - -# --------------------------------------------------------------------------- -# Serialisation helpers -# --------------------------------------------------------------------------- - - -def _serialise(obj: Any) -> Any: - """Recursively convert a Python value to JSON-safe primitives.""" - if obj is None: - return None - if isinstance(obj, (str, int, float, bool)): - return obj - if isinstance(obj, Decimal): - return float(obj) - if isinstance(obj, (datetime.date, datetime.datetime)): - return obj.isoformat() - if isinstance(obj, datetime.timedelta): - return obj.total_seconds() - if isinstance(obj, dict): - return {str(k): _serialise(v) for k, v in obj.items()} - if hasattr(obj, "_asdict"): - return _serialise(obj._asdict()) - if isinstance(obj, list): - return [_serialise(v) for v in obj] - if hasattr(obj, "model_dump"): - return _serialise(obj.model_dump()) - if hasattr(obj, "__dataclass_fields__"): - import dataclasses - - return _serialise(dataclasses.asdict(obj)) - if hasattr(obj, "value"): - return _serialise(obj.value) - return str(obj) - - -def _unwrap_intent_result(result) -> Dict[str, Any]: - """Convert an IntentResult to a plain dict for JSON-RPC response.""" - return { - "ok": result.was_intent_successful, - "data": _serialise(result.data), - "error": result.error_msg or None, - } - - -# --------------------------------------------------------------------------- -# Entity serialisation helpers (include nested relations) -# --------------------------------------------------------------------------- - - -def _client_to_rpc_dict(client) -> Dict[str, Any]: - """Serialise a Client with nested invoicing_contact and address.""" - d = _serialise(client) - ic = client.invoicing_contact - if ic: - icd = _serialise(ic) - if ic.address: - icd["address"] = _serialise(ic.address) - d["invoicing_contact"] = icd - return d - - -def _contract_to_rpc_dict(contract, include_relations: bool = True) -> Dict[str, Any]: - """Serialise a Contract with nested client and relationship counts.""" - d = _serialise(contract) - if contract.client: - d["client"] = _serialise(contract.client) - if include_relations: - d["projects"] = [ - {"id": p.id, "title": p.title} for p in (contract.projects or []) - ] - d["invoices"] = [{"id": inv.id} for inv in (contract.invoices or [])] - return d - - -def _project_to_rpc_dict(project) -> Dict[str, Any]: - """Serialise a Project with nested contract (and its client), no circular refs.""" - d = _serialise(project) - if project.contract: - d["contract"] = _contract_to_rpc_dict(project.contract, include_relations=False) - return d - - -def _patch_scalars_from_rpc(instance: Any, updates: Dict[str, Any], skip: set) -> None: - """Apply JSON payload fields onto a persisted SQLModel row (in-place). - - Handles date coercion for *_date fields and empty-string-to-None for optional dates. - """ - for k, v in updates.items(): - if k in skip or k.startswith("_"): - continue - if v == "" and k.endswith("_date"): - v = None - if isinstance(v, str) and k.endswith("_date") and v and len(v) >= 10: - try: - v = datetime.date.fromisoformat(v[:10]) - except ValueError: - pass - setattr(instance, k, v) - - -# --------------------------------------------------------------------------- -# Time-tracking helpers (DataFrame → JSON) -# --------------------------------------------------------------------------- - - -def _timetracking_df_to_records(df) -> list: - """Convert a time-tracking DataFrame to a list of JSON-safe dicts.""" - if df is None or df.empty: - return [] - records = [] - for idx, row in df.iterrows(): - begin = idx - if hasattr(begin, "isoformat"): - begin = begin.isoformat() - end = row.get("end") - if hasattr(end, "isoformat"): - end = end.isoformat() - dur = row.get("duration") - dur_hours = dur.total_seconds() / 3600 if hasattr(dur, "total_seconds") else 0 - records.append( - { - "begin": str(begin), - "end": str(end) if end is not None else None, - "duration_hours": round(dur_hours, 2), - "title": str(row.get("title", "")), - "tag": str(row.get("tag", "")), - "description": str(row.get("description", "") or ""), - "all_day": bool(row.get("all_day", False)), - "date": str(begin)[:10], - } - ) - return records - - -def _build_calendar_data(df, year: int, month: int, project_tag=None) -> dict: - """Build a month-view calendar payload from a time-tracking DataFrame. - - Returns a dict with ``events`` (records for this month), ``projects`` - (unique tags with hours), ``days`` (per-day aggregation for the grid), - and ``summary`` (totals). - """ - import calendar as cal_mod - - start = datetime.date(year, month, 1) - _, last_day = cal_mod.monthrange(year, month) - end = datetime.date(year, month, last_day) - - mask = (df.index.date >= start) & (df.index.date <= end) - month_df = df[mask] - if project_tag: - month_df = month_df[month_df["tag"] == project_tag] - - events = _timetracking_df_to_records(month_df) - - by_tag = ( - month_df.groupby("tag")["duration"] - .sum() - .apply(lambda td: round(td.total_seconds() / 3600, 1)) - .to_dict() - ) - projects = [ - {"tag": t, "hours": h} for t, h in sorted(by_tag.items(), key=lambda x: -x[1]) - ] - - days: dict = {} - for idx, row in month_df.iterrows(): - d = str(idx.date()) if hasattr(idx, "date") else str(idx)[:10] - if d not in days: - days[d] = {"date": d, "hours": 0.0, "tags": [], "count": 0} - dur = row.get("duration") - h = dur.total_seconds() / 3600 if hasattr(dur, "total_seconds") else 0 - days[d]["hours"] = round(days[d]["hours"] + h, 2) - days[d]["count"] += 1 - tag = str(row.get("tag", "")) - if tag and tag not in days[d]["tags"]: - days[d]["tags"].append(tag) - - total_hours = ( - round(month_df["duration"].sum().total_seconds() / 3600, 1) - if len(month_df) - else 0 - ) - - return { - "year": year, - "month": month, - "first_weekday": start.weekday(), - "days_in_month": last_day, - "events": events, - "projects": projects, - "days": days, - "summary": { - "total_events": len(month_df), - "total_hours": total_hours, - }, - } - - -# --------------------------------------------------------------------------- -# Method dispatch table -# --------------------------------------------------------------------------- - - -def _get_app_db(): - from tuttle.app_db import AppDatabase - - return AppDatabase() - - -def _ensure_user_db(db_path: Path): - """Ensure a per-user database exists and migrations are applied.""" - from tuttle.migrations.run import run_migrations - - run_migrations(f"sqlite:///{db_path}") - - -def _switch_to_user_db(db_file: str): - """Switch the active per-user database and reset all intent singletons.""" - from tuttle.app.core.abstractions import set_active_db - - app_db = _get_app_db() - db_path = app_db.get_user_db_path(db_file) - _ensure_user_db(db_path) - set_active_db(db_path) - app_db.set_active(db_file) - _reset_intents() - logger.info(f"Switched to user DB: {db_file}") - - if db_file == "harry-tuttle.db": - _ensure_demo_timetracking(db_path) - - -def _ensure_demo_timetracking(db_path=None): - """Repopulate demo time-tracking data for the Harry Tuttle demo user only.""" - ds = _get_intent("timetracking_ds") - if ds.get_data_frame() is not None: - return - try: - from sqlmodel import Session, create_engine, select - from tuttle.model import Project - from tuttle.demo import create_fake_calendar - from tuttle.calendar import ICSCalendar - - if db_path is None: - from tuttle.app.core.abstractions import get_active_db - - db_path = get_active_db() - - engine = create_engine(f"sqlite:///{db_path}") - with Session(engine) as session: - projects = session.exec(select(Project)).all() - if not projects: - return - cal = ICSCalendar( - name="Demo calendar", ics_calendar=create_fake_calendar(list(projects)) - ) - df = cal.to_data() - ds.store_data_frame(df) - logger.info(f"Repopulated {len(df)} demo time-tracking events") - except Exception as ex: - logger.warning(f"Could not repopulate demo timetracking: {ex}") - - -def _ensure_demo_user( - invoice_language: str = "en", - invoice_template: str = "invoice-modern", -): - """Ensure the Harry Tuttle demo user is registered (does not install data).""" - from tuttle.demo import install_demo_data - from tuttle.migrations.run import run_migrations - - app_db = _get_app_db() - if app_db.get_user_by_db_file("harry-tuttle.db"): - return - reg = app_db.add_user( - name="Harry Tuttle", - subtitle="Heating Engineer", - is_demo=True, - db_file="harry-tuttle.db", - ) - db_path = app_db.get_user_db_path(reg.db_file) - if db_path.exists(): - db_path.unlink() - run_migrations(f"sqlite:///{db_path}") - - def _cache_demo_timetracking(df): - ds = _get_intent("timetracking_ds") - ds.store_data_frame(df) - logger.info(f"Cached {len(df)} demo time-tracking events") - - install_demo_data( - n_projects=4, - db_path=str(db_path), - on_cache_timetracking_dataframe=_cache_demo_timetracking, - invoice_language=invoice_language, - invoice_template=invoice_template, - ) - logger.info("Demo user Harry Tuttle created with heating-repair data") - - -def _ensure_db(): - """Ensure app.db + demo user + last-active user DB exist and are migrated.""" - app_db = _get_app_db() - app_db.ensure() - app_db.migrate_llm_config_from_json() - _ensure_demo_user() - - last = app_db.get_last_active() - if last: - _switch_to_user_db(last.db_file) - else: - users = app_db.list_users() - if users: - _switch_to_user_db(users[0].db_file) - - -def _dispatch(method: str, params: Dict[str, Any]) -> Any: - """Dispatch a JSON-RPC method string to the appropriate intent call.""" - - # -- Contacts ----------------------------------------------------------- - if method == "contacts.get_all": - result = _get_intent("contacts").get_all() - if result.was_intent_successful and result.data: - enriched = [] - for c in result.data: - d = _serialise(c) - if c.address: - d["address"] = _serialise(c.address) - enriched.append(d) - return {"ok": True, "data": enriched, "error": None} - return _unwrap_intent_result(result) - if method == "contacts.get_by_id": - result = _get_intent("contacts").get_by_id(params["id"]) - if result.was_intent_successful and result.data: - d = _serialise(result.data) - if result.data.address: - d["address"] = _serialise(result.data.address) - return {"ok": True, "data": d, "error": None} - return _unwrap_intent_result(result) - if method == "contacts.save": - from tuttle.model import Contact, Address - - data = params["contact"] - addr_data = data.pop("address", {}) or {} - intent = _get_intent("contacts") - contact_id = data.get("id") - if contact_id: - existing = intent.get_by_id(contact_id) - if not existing.was_intent_successful or not existing.data: - return {"ok": False, "data": None, "error": "Contact not found"} - contact = existing.data - for k, v in data.items(): - if not k.startswith("_") and k not in ( - "id", - "invoicing_contact_of", - "address", - ): - setattr(contact, k, v) - if contact.address: - for k, v in addr_data.items(): - if not k.startswith("_") and k != "id": - setattr(contact.address, k, v) - elif addr_data: - address = Address( - **{ - k: v - for k, v in addr_data.items() - if k != "id" and not k.startswith("_") - } - ) - contact.address = address - else: - address = Address( - **{ - k: v - for k, v in addr_data.items() - if k != "id" and not k.startswith("_") - } - ) - contact = Contact( - address=address, - **{ - k: v - for k, v in data.items() - if not k.startswith("_") and k not in ("invoicing_contact_of",) - }, - ) - return _unwrap_intent_result(intent.save_contact(contact)) - if method == "contacts.delete": - return _unwrap_intent_result( - _get_intent("contacts").delete_contact(params["id"]) - ) - - # -- Clients ------------------------------------------------------------ - if method == "clients.get_all": - result = _get_intent("clients").get_all() - if result.was_intent_successful and result.data: - enriched = [_client_to_rpc_dict(c) for c in result.data] - return {"ok": True, "data": enriched, "error": None} - return _unwrap_intent_result(result) - if method == "clients.get_by_id": - result = _get_intent("clients").get_by_id(params["id"]) - if result.was_intent_successful and result.data: - return {"ok": True, "data": _client_to_rpc_dict(result.data), "error": None} - return _unwrap_intent_result(result) - if method == "clients.get_all_contacts": - contacts_map = _get_intent("clients").get_all_contacts_as_map() - return {"ok": True, "data": _serialise(contacts_map), "error": None} - if method == "clients.save": - from tuttle.model import Client, Contact, Address - - raw = params["client"] - data = dict(raw) - contact_data = dict(data.pop("invoicing_contact", {}) or {}) - addr_data = dict(contact_data.pop("address", {}) or {}) - client_id = data.get("id") - intent = _get_intent("clients") - contacts_intent = _get_intent("contacts") - - if client_id: - existing = intent.get_by_id(client_id) - if not existing.was_intent_successful or not existing.data: - return {"ok": False, "data": None, "error": "Client not found"} - client = existing.data - for k, v in data.items(): - if not k.startswith("_") and k not in ( - "id", - "contracts", - "invoicing_contact_id", - "invoicing_contact", - ): - setattr(client, k, v) - - contact_id = contact_data.get("id") - if contact_id: - cres = contacts_intent.get_by_id(contact_id) - if not cres.was_intent_successful or not cres.data: - return { - "ok": False, - "data": None, - "error": "Invoicing contact not found", - } - contact = cres.data - for k, v in contact_data.items(): - if not k.startswith("_") and k not in ( - "id", - "invoicing_contact_of", - "address", - "address_id", - ): - setattr(contact, k, v) - if contact.address: - for k, v in addr_data.items(): - if not k.startswith("_") and k != "id": - setattr(contact.address, k, v) - elif addr_data: - contact.address = Address( - **{ - k: v - for k, v in addr_data.items() - if k != "id" and not k.startswith("_") - } - ) - client.invoicing_contact = contact - else: - address = Address( - **{ - k: v - for k, v in addr_data.items() - if k != "id" and not k.startswith("_") - } - ) - contact = Contact( - address=address, - **{ - k: v - for k, v in contact_data.items() - if not k.startswith("_") - and k not in ("invoicing_contact_of", "address_id") - }, - ) - client.invoicing_contact = contact - return _unwrap_intent_result(intent.save_client(client)) - - address = Address( - **{ - k: v - for k, v in addr_data.items() - if k != "id" and not k.startswith("_") - } - ) - if addr_data.get("id"): - address.id = addr_data["id"] - contact = Contact( - address=address, - **{ - k: v - for k, v in contact_data.items() - if not k.startswith("_") and k not in ("invoicing_contact_of",) - }, - ) - if contact_data.get("id"): - contact.id = contact_data["id"] - client = Client( - invoicing_contact=contact, - **{ - k: v - for k, v in data.items() - if not k.startswith("_") and k not in ("contracts",) - }, - ) - return _unwrap_intent_result(intent.save_client(client)) - if method == "clients.delete": - return _unwrap_intent_result(_get_intent("clients").delete(params["id"])) - - # -- Contracts ---------------------------------------------------------- - if method == "contracts.get_all": - result = _get_intent("contracts").get_all() - if result.was_intent_successful and result.data: - enriched = [_contract_to_rpc_dict(c) for c in result.data] - return {"ok": True, "data": enriched, "error": None} - return _unwrap_intent_result(result) - if method == "contracts.get_by_id": - result = _get_intent("contracts").get_by_id(params["id"]) - if result.was_intent_successful and result.data: - return { - "ok": True, - "data": _contract_to_rpc_dict(result.data), - "error": None, - } - return _unwrap_intent_result(result) - if method == "contracts.get_all_clients": - clients_map = _get_intent("contracts").get_all_clients_as_map() - return {"ok": True, "data": _serialise(clients_map), "error": None} - if method == "contracts.get_default_currency": - return _unwrap_intent_result(_get_intent("contracts").get_default_currency()) - if method == "contracts.save": - from tuttle.model import Contract - - data = params["contract"] - clean = { - k: v - for k, v in data.items() - if not k.startswith("_") and k not in ("client", "projects", "invoices") - } - contract_id = clean.get("id") - intent = _get_intent("contracts") - skip_patch = {"id", "client", "projects", "invoices"} - - if contract_id: - res = intent.get_by_id(contract_id) - if not res.was_intent_successful or not res.data: - return {"ok": False, "data": None, "error": "Contract not found"} - contract = res.data - _patch_scalars_from_rpc(contract, clean, skip_patch) - return _unwrap_intent_result(intent.save_contract(contract)) - - new_data = {k: v for k, v in clean.items() if k != "id"} - for k in list(new_data.keys()): - if k.endswith("_date") and isinstance(new_data[k], str) and new_data[k]: - try: - new_data[k] = datetime.date.fromisoformat(new_data[k][:10]) - except ValueError: - pass - elif k.endswith("_date") and new_data[k] == "": - new_data[k] = None - return _unwrap_intent_result(intent.save_contract(Contract(**new_data))) - if method == "contracts.delete": - return _unwrap_intent_result(_get_intent("contracts").delete(params["id"])) - if method == "contracts.toggle_completed": - result = _get_intent("contracts").get_by_id(params["id"]) - if not result.was_intent_successful: - return _unwrap_intent_result(result) - return _unwrap_intent_result( - _get_intent("contracts").toggle_complete_status(result.data) - ) - - # -- Projects ----------------------------------------------------------- - if method == "projects.get_all": - result = _get_intent("projects").get_all() - if result.was_intent_successful and result.data: - enriched = [_project_to_rpc_dict(p) for p in result.data] - return {"ok": True, "data": enriched, "error": None} - return _unwrap_intent_result(result) - if method == "projects.get_by_id": - result = _get_intent("projects").get_by_id(params["id"]) - if result.was_intent_successful and result.data: - return { - "ok": True, - "data": _project_to_rpc_dict(result.data), - "error": None, - } - return _unwrap_intent_result(result) - if method == "projects.get_all_clients": - clients_map = _get_intent("projects").get_all_clients_as_map() - return {"ok": True, "data": _serialise(clients_map), "error": None} - if method == "projects.get_all_contracts": - contracts_map = _get_intent("projects").get_all_contracts_as_map() - return {"ok": True, "data": _serialise(contracts_map), "error": None} - if method == "projects.save": - from tuttle.model import Project - - data = params["project"] - clean = { - k: v - for k, v in data.items() - if not k.startswith("_") and k not in ("contract", "timesheets", "invoices") - } - project_id = clean.get("id") - intent = _get_intent("projects") - skip_patch = {"id", "contract", "timesheets", "invoices"} - - if project_id: - res = intent.get_by_id(project_id) - if not res.was_intent_successful or not res.data: - return {"ok": False, "data": None, "error": "Project not found"} - project = res.data - _patch_scalars_from_rpc(project, clean, skip_patch) - return _unwrap_intent_result(intent.save_project(project)) - - new_data = {k: v for k, v in clean.items() if k != "id"} - for k in list(new_data.keys()): - if k.endswith("_date") and isinstance(new_data[k], str) and new_data[k]: - try: - new_data[k] = datetime.date.fromisoformat(new_data[k][:10]) - except ValueError: - pass - elif k.endswith("_date") and new_data[k] == "": - new_data[k] = None - return _unwrap_intent_result(intent.save_project(Project(**new_data))) - if method == "projects.delete": - return _unwrap_intent_result(_get_intent("projects").delete(params["id"])) - if method == "projects.toggle_completed": - result = _get_intent("projects").get_by_id(params["id"]) - if not result.was_intent_successful: - return _unwrap_intent_result(result) - return _unwrap_intent_result( - _get_intent("projects").toggle_project_completed_status(result.data) - ) - - # -- Invoicing ---------------------------------------------------------- - if method == "invoicing.get_all": - from tuttle.app.core.formatting import fmt_currency - - ds = _get_intent("invoicing_ds") - result = ds.get_all_invoices() - if result.was_intent_successful and result.data: - enriched = [] - for inv in result.data: - d = _serialise(inv) - currency = "EUR" - if inv.contract: - currency = inv.contract.currency or "EUR" - d["contract_title"] = inv.contract.title or "" - else: - d["contract_title"] = "" - d["currency"] = currency - d["client_name"] = "" - d["project_title"] = "" - if inv.contract and inv.contract.client: - d["client_name"] = inv.contract.client.name or "" - if inv.project: - d["project_title"] = inv.project.title or "" - d["sum_value"] = float(inv.sum) - d["sum_formatted"] = fmt_currency(inv.sum, currency) - d["vat_total_value"] = float(inv.VAT_total) - d["vat_total_formatted"] = fmt_currency(inv.VAT_total, currency) - d["total_value"] = float(inv.total) - d["total_formatted"] = fmt_currency(inv.total, currency) - items_enriched = [] - for item in inv.items or []: - item_d = _serialise(item) - item_d["unit_price_formatted"] = fmt_currency( - item.unit_price, currency - ) - item_d["subtotal_value"] = float(item.subtotal) - item_d["subtotal_formatted"] = fmt_currency(item.subtotal, currency) - items_enriched.append(item_d) - d["items"] = items_enriched - if inv.rendered and inv.file_name: - pdf = Path.home() / ".tuttle" / "Invoices" / inv.file_name - d["pdf_path"] = str(pdf) if pdf.exists() else None - else: - d["pdf_path"] = None - enriched.append(d) - return {"ok": True, "data": enriched, "error": None} - return _unwrap_intent_result(result) - if method == "invoicing.delete": - return _unwrap_intent_result( - _get_intent("invoicing").delete_invoice_by_id(params["id"]) - ) - if method == "invoicing.create": - intent = _get_intent("invoicing") - proj_result = _get_intent("projects").get_by_id(params["project_id"]) - if not proj_result.was_intent_successful: - return _unwrap_intent_result(proj_result) - project = proj_result.data - invoice_date = datetime.date.fromisoformat(params["invoice_date"]) - from_date = datetime.date.fromisoformat(params["from_date"]) - to_date = datetime.date.fromisoformat(params["to_date"]) - manual_qty = params.get("manual_quantity") - app_db = _get_app_db() - from tuttle.app.preferences.model import ( - PreferencesStorageKeys, - DEFAULT_INVOICE_TEMPLATE, - ) - - language = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" - template_name = ( - app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) - or DEFAULT_INVOICE_TEMPLATE - ) - return _unwrap_intent_result( - intent.create_invoice( - invoice_date=invoice_date, - project=project, - from_date=from_date, - to_date=to_date, - render=params.get("render", True), - manual_quantity=manual_qty, - language=language, - template_name=template_name, - ) - ) - if method == "invoicing.toggle_sent": - ds = _get_intent("invoicing_ds") - result = ds.get_all_invoices() - if not result.was_intent_successful: - return _unwrap_intent_result(result) - invoice = next((i for i in result.data if i.id == params["id"]), None) - if not invoice: - return {"ok": False, "data": None, "error": "Invoice not found"} - return _unwrap_intent_result( - _get_intent("invoicing").toggle_invoice_sent_status(invoice) - ) - if method == "invoicing.toggle_paid": - ds = _get_intent("invoicing_ds") - result = ds.get_all_invoices() - if not result.was_intent_successful: - return _unwrap_intent_result(result) - invoice = next((i for i in result.data if i.id == params["id"]), None) - if not invoice: - return {"ok": False, "data": None, "error": "Invoice not found"} - return _unwrap_intent_result( - _get_intent("invoicing").toggle_invoice_paid_status(invoice) - ) - if method == "invoicing.toggle_cancelled": - ds = _get_intent("invoicing_ds") - result = ds.get_all_invoices() - if not result.was_intent_successful: - return _unwrap_intent_result(result) - invoice = next((i for i in result.data if i.id == params["id"]), None) - if not invoice: - return {"ok": False, "data": None, "error": "Invoice not found"} - return _unwrap_intent_result( - _get_intent("invoicing").toggle_invoice_cancelled_status(invoice) - ) - - # -- Dashboard ---------------------------------------------------------- - if method == "dashboard.get_kpis": - result = _get_intent("dashboard").get_kpis() - if result.was_intent_successful and result.data is not None: - from tuttle.app.core.formatting import fmt_currency - - kpi = result.data - d = _serialise(kpi) - tc = d.get("tax_currency", "EUR") - d["total_revenue_ytd_formatted"] = fmt_currency(kpi.total_revenue_ytd, tc) - d["outstanding_amount_formatted"] = fmt_currency(kpi.outstanding_amount, tc) - d["overdue_amount_formatted"] = fmt_currency(kpi.overdue_amount, tc) - d["vat_reserve_formatted"] = fmt_currency(kpi.vat_reserve, tc) - d["income_tax_reserve_formatted"] = fmt_currency(kpi.income_tax_reserve, tc) - d["spendable_income_formatted"] = fmt_currency(kpi.spendable_income, tc) - if kpi.effective_hourly_rate is not None: - d["effective_hourly_rate_formatted"] = fmt_currency( - kpi.effective_hourly_rate, tc - ) - else: - d["effective_hourly_rate_formatted"] = "—" - if kpi.utilization_rate is not None: - d["utilization_rate_formatted"] = f"{kpi.utilization_rate * 100:.0f}%" - else: - d["utilization_rate_formatted"] = "—" - return {"ok": True, "data": d, "error": None} - return _unwrap_intent_result(result) - if method == "dashboard.get_monthly_chart_data": - n = params.get("n_months", 12) - return _unwrap_intent_result( - _get_intent("dashboard").get_monthly_chart_data(n_months=n) - ) - if method == "dashboard.get_project_budgets": - return _unwrap_intent_result(_get_intent("dashboard").get_project_budgets()) - if method == "dashboard.get_financial_goals": - return _unwrap_intent_result(_get_intent("dashboard").get_financial_goals()) - - # -- Timeline ----------------------------------------------------------- - if method == "timeline.get_events": - cat = params.get("category_filter") - return _unwrap_intent_result( - _get_intent("timeline").get_timeline_events(category_filter=cat) - ) - - # -- Tax & Reserves ----------------------------------------------------- - if method == "tax.get_spendable_income": - return _unwrap_intent_result(_get_intent("tax").get_spendable_income()) - - if method == "tax.get_income_tax_estimate": - return _unwrap_intent_result(_get_intent("tax").get_income_tax_estimate()) - - if method == "tax.get_quarterly_vat": - year = params.get("year") - return _unwrap_intent_result(_get_intent("tax").get_quarterly_vat(year=year)) - - # -- Salary ------------------------------------------------------------- - if method == "salary.get_effective_salary": - return _unwrap_intent_result(_get_intent("salary").get_effective_salary()) - - if method == "salary.get_expenses": - return _unwrap_intent_result(_get_intent("salary").get_expenses()) - - if method == "salary.save_expense": - from tuttle.model import RecurringExpense - - data = params["expense"] - expense_id = data.get("id") - if expense_id: - result = _get_intent("salary").get_expenses() - if result.was_intent_successful and result.data: - existing = next((e for e in result.data if e.id == expense_id), None) - if existing: - for k, v in data.items(): - if k != "id" and not k.startswith("_"): - setattr(existing, k, v) - return _unwrap_intent_result( - _get_intent("salary").save_expense(existing) - ) - clean = {k: v for k, v in data.items() if k != "id" and not k.startswith("_")} - return _unwrap_intent_result( - _get_intent("salary").save_expense(RecurringExpense(**clean)) - ) - - if method == "salary.delete_expense": - return _unwrap_intent_result(_get_intent("salary").delete_expense(params["id"])) - - # -- LLM --------------------------------------------------------------- - if method == "llm.get_config": - from tuttle.llm import load_config - - config = load_config() - return {"ok": True, "data": config.model_dump(), "error": None} - - if method == "llm.save_config": - from tuttle.llm import LLMConfig, save_config - - config = LLMConfig(**params.get("config", {})) - saved = save_config(config) - return {"ok": True, "data": saved.model_dump(), "error": None} - - if method == "llm.get_models": - from tuttle.llm import get_available_models - - base_url = params.get("base_url", "http://localhost:11434") - models = get_available_models(base_url) - return {"ok": True, "data": models, "error": None} - - if method == "llm.parse_document": - from tuttle.llm import parse_document, load_config as _load_llm - - file_base64 = params["file_base64"] - file_name = params["file_name"] - entity_type = params.get("entity_type", "contact") - config = _load_llm() - items = parse_document(file_base64, file_name, entity_type, config) - return {"ok": True, "data": items, "error": None} - - # -- Users -------------------------------------------------------------- - if method == "users.list": - app_db = _get_app_db() - users = app_db.list_users() - return { - "ok": True, - "data": [_serialise(u) for u in users], - "error": None, - } - - if method == "users.create": - from tuttle.model import User, Address, BankAccount - from tuttle.migrations.run import run_migrations - from sqlmodel import Session as SqlSession, create_engine as sql_create_engine - - app_db = _get_app_db() - name = params["name"] - subtitle = params.get("subtitle", "") - reg = app_db.add_user(name=name, subtitle=subtitle) - db_path = app_db.get_user_db_path(reg.db_file) - run_migrations(f"sqlite:///{db_path}") - engine = sql_create_engine(f"sqlite:///{db_path}") - with SqlSession(engine) as s: - address = Address( - street=params.get("street", ""), - number=params.get("street_num", ""), - postal_code=params.get("postal_code", ""), - city=params.get("city", ""), - country=params.get("country", ""), - ) - user = User( - name=name, - subtitle=subtitle, - email=params.get("email", ""), - phone_number=params.get("phone", ""), - website=params.get("website", ""), - operating_country=params.get("operating_country", "Germany"), - VAT_number=params.get("vat_number", ""), - address=address, - ) - s.add(user) - s.commit() - engine.dispose() - _switch_to_user_db(reg.db_file) - return {"ok": True, "data": _serialise(reg), "error": None} - - if method == "users.switch": - db_file = params["db_file"] - _switch_to_user_db(db_file) - return {"ok": True, "data": None, "error": None} - - if method == "users.delete": - db_file = params["db_file"] - app_db = _get_app_db() - removed = app_db.remove_user(db_file) - if removed: - _reset_intents() - return {"ok": True, "data": removed, "error": None} - - if method == "users.get_active": - from tuttle.app.core.abstractions import get_active_db - - app_db = _get_app_db() - active_path = get_active_db() - active_file = active_path.name - reg = app_db.get_user_by_db_file(active_file) - if not reg: - return {"ok": True, "data": None, "error": None} - from tuttle.app.auth.data_source import UserDataSource - - try: - ds = UserDataSource() - profile = ds.get_user() - data = _serialise(reg) - if profile: - data["profile"] = _serialise(profile) - if profile.address: - data["profile"]["address"] = _serialise(profile.address) - return {"ok": True, "data": data, "error": None} - except Exception: - return {"ok": True, "data": _serialise(reg), "error": None} - - if method == "users.update_profile": - from tuttle.app.auth.data_source import UserDataSource - from tuttle.app.core.abstractions import get_active_db - - ds = UserDataSource() - profile = ds.get_user() - if not profile: - return {"ok": False, "data": None, "error": "No user profile found"} - - data = params.get("profile", {}) - for k in ( - "name", - "subtitle", - "email", - "phone_number", - "website", - "VAT_number", - "operating_country", - ): - if k in data: - setattr(profile, k, data[k]) - - addr = data.get("address") - if addr: - if profile.address: - for k in ("street", "number", "postal_code", "city", "country"): - if k in addr: - setattr(profile.address, k, addr[k]) - else: - from tuttle.model import Address - - profile.address = Address( - **{ - k: v - for k, v in addr.items() - if k != "id" and not k.startswith("_") - } - ) - - with ds.create_session() as s: - s.add(profile) - s.commit() - s.refresh(profile) - - app_db = _get_app_db() - active_file = get_active_db().name - reg = app_db.get_user_by_db_file(active_file) - if reg and ( - reg.name != profile.name or reg.subtitle != (profile.subtitle or "") - ): - with app_db._session() as s: - db_reg = s.get(type(reg), reg.id) - if db_reg: - db_reg.name = profile.name - db_reg.subtitle = profile.subtitle or "" - s.add(db_reg) - s.commit() - - result = _serialise(profile) - if profile.address: - result["address"] = _serialise(profile.address) - return {"ok": True, "data": result, "error": None} - - if method == "users.ensure_demo": - _ensure_demo_user() - app_db = _get_app_db() - reg = app_db.get_user_by_db_file("harry-tuttle.db") - return {"ok": True, "data": _serialise(reg) if reg else None, "error": None} - - # -- Preferences ------------------------------------------------------- - if method == "preferences.get": - app_db = _get_app_db() - from tuttle.app.preferences.model import ( - PreferencesStorageKeys, - DEFAULT_INVOICE_TEMPLATE, - ) - - data = { - "invoice_template": ( - app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) - or DEFAULT_INVOICE_TEMPLATE - ), - "language": ( - app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" - ), - } - return {"ok": True, "data": data, "error": None} - - if method == "preferences.save": - app_db = _get_app_db() - from tuttle.app.preferences.model import PreferencesStorageKeys - - if "invoice_template" in params: - app_db.set_setting( - PreferencesStorageKeys.invoice_template_key.value, - params["invoice_template"], - ) - if "language" in params: - app_db.set_setting( - PreferencesStorageKeys.language_key.value, - params["language"], - ) - return {"ok": True, "data": None, "error": None} - - if method == "tax.supported_countries": - from tuttle.tax import supported_countries - - return {"ok": True, "data": supported_countries(), "error": None} - - if method == "invoicing.available_templates": - from tuttle.app.preferences.model import INVOICE_TEMPLATES - - return {"ok": True, "data": INVOICE_TEMPLATES, "error": None} - - if method == "invoicing.available_languages": - from tuttle.app.preferences.model import SUPPORTED_INVOICE_LANGUAGES - - return {"ok": True, "data": SUPPORTED_INVOICE_LANGUAGES, "error": None} - - # -- Settings ----------------------------------------------------------- - if method == "settings.get": - app_db = _get_app_db() - val = app_db.get_setting(params["key"]) - return {"ok": True, "data": val, "error": None} - - if method == "settings.set": - app_db = _get_app_db() - app_db.set_setting(params["key"], params["value"]) - return {"ok": True, "data": None, "error": None} - - if method == "settings.get_all": - app_db = _get_app_db() - prefix = params.get("prefix") - data = app_db.get_all_settings(prefix=prefix) - return {"ok": True, "data": data, "error": None} - - # -- Time Tracking ------------------------------------------------------ - if method == "timetracking.get_events": - ds = _get_intent("timetracking_ds") - df = ds.get_data_frame() - if df is None or df.empty: - return {"ok": True, "data": [], "error": None} - project_tag = params.get("project_tag") - if project_tag: - df = df[df["tag"] == project_tag] - records = _timetracking_df_to_records(df) - return {"ok": True, "data": records, "error": None} - - if method == "timetracking.get_calendar_data": - ds = _get_intent("timetracking_ds") - df = ds.get_data_frame() - if df is None or df.empty: - return { - "ok": True, - "data": {"events": [], "projects": [], "summary": {}}, - "error": None, - } - project_tag = params.get("project_tag") - year = params.get("year", datetime.date.today().year) - month = params.get("month", datetime.date.today().month) - data = _build_calendar_data(df, year, month, project_tag) - return {"ok": True, "data": data, "error": None} - - if method == "timetracking.import_ics": - import base64 - - content_b64 = params["content"] - name = params.get("name", "imported.ics") - raw = base64.b64decode(content_b64) - from tuttle.calendar import ICSCalendar - - cal = ICSCalendar(name=name, content=raw) - new_df = cal.to_data() - ds = _get_intent("timetracking_ds") - existing = ds.get_data_frame() - if existing is not None and not existing.empty: - import pandas - - combined = pandas.concat([existing, new_df]) - combined = combined[~combined.index.duplicated(keep="last")] - ds.store_data_frame(combined) - else: - ds.store_data_frame(new_df) - records = _timetracking_df_to_records(new_df) - return { - "ok": True, - "data": {"imported_count": len(records), "events": records}, - "error": None, - } - - if method == "timetracking.clear": - ds = _get_intent("timetracking_ds") - ds.store_data_frame(None) - return {"ok": True, "data": None, "error": None} - - if method == "timetracking.list_system_calendars": - from tuttle.eventkit_bridge import ( - is_available, - list_calendars_with_status, - open_calendar_privacy_settings, - ) - - if not is_available(): - return { - "ok": True, - "data": {"calendars": [], "auth_status": "not_available"}, - "error": None, - } - - if params.get("open_settings"): - open_calendar_privacy_settings() - return { - "ok": True, - "data": {"calendars": [], "auth_status": "pending"}, - "error": None, - } - - try: - data = list_calendars_with_status() - return {"ok": True, "data": data, "error": None} - except Exception as ex: - logger.exception(ex) - return { - "ok": False, - "data": {"calendars": [], "auth_status": "unknown"}, - "error": str(ex), - } - - if method == "timetracking.import_system_calendar": - from tuttle.eventkit_bridge import is_available, fetch_events - - if not is_available(): - return { - "ok": False, - "data": None, - "error": "System calendar access is only available on macOS", - } - calendar_id = params["calendar_id"] - from_date = datetime.date.fromisoformat( - params.get( - "from_date", - (datetime.date.today() - datetime.timedelta(days=365)).isoformat(), - ) - ) - to_date = datetime.date.fromisoformat( - params.get("to_date", datetime.date.today().isoformat()) - ) - try: - new_df = fetch_events(calendar_id, from_date, to_date) - if new_df.empty: - return { - "ok": True, - "data": {"imported_count": 0, "events": []}, - "error": None, - } - ds = _get_intent("timetracking_ds") - existing = ds.get_data_frame() - if existing is not None and not existing.empty: - import pandas - - combined = pandas.concat([existing, new_df]) - combined = combined[~combined.index.duplicated(keep="last")] - ds.store_data_frame(combined) - else: - ds.store_data_frame(new_df) - records = _timetracking_df_to_records(new_df) - return { - "ok": True, - "data": {"imported_count": len(records), "events": records}, - "error": None, - } - except Exception as ex: - logger.exception(ex) - return {"ok": False, "data": None, "error": str(ex)} - - if method == "timetracking.get_summary": - ds = _get_intent("timetracking_ds") - df = ds.get_data_frame() - if df is None or df.empty: - return { - "ok": True, - "data": {"total_events": 0, "total_hours": 0, "projects": []}, - "error": None, - } - total_hours = df["duration"].sum().total_seconds() / 3600 - by_tag = ( - df.groupby("tag")["duration"] - .sum() - .apply(lambda td: round(td.total_seconds() / 3600, 1)) - .to_dict() - ) - projects_intent = _get_intent("projects") - proj_result = projects_intent.get_all() - tag_to_project = {} - if proj_result.was_intent_successful and proj_result.data: - tag_to_project = {p.tag: p.title for p in proj_result.data} - project_summaries = [] - for tag, hours in sorted(by_tag.items(), key=lambda x: -x[1]): - project_summaries.append( - { - "tag": tag, - "title": tag_to_project.get(tag, tag), - "hours": hours, - "event_count": int((df["tag"] == tag).sum()), - } - ) - return { - "ok": True, - "data": { - "total_events": len(df), - "total_hours": round(total_hours, 1), - "projects": project_summaries, - }, - "error": None, - } - - # -- Demo --------------------------------------------------------------- - if method == "demo.install": - result = _dispatch("users.ensure_demo", {}) - if result.get("ok") and result.get("data"): - db_file = result["data"].get("db_file", "harry-tuttle.db") - _switch_to_user_db(db_file) - return result - - if method == "demo.reset": - app_db = _get_app_db() - from tuttle.app.preferences.model import ( - PreferencesStorageKeys as _PK, - DEFAULT_INVOICE_TEMPLATE as _DIT, - ) - - lang = app_db.get_setting(_PK.language_key.value) or "en" - tmpl = app_db.get_setting(_PK.invoice_template_key.value) or _DIT - app_db.remove_user("harry-tuttle.db") - _reset_intents() - _ensure_demo_user(invoice_language=lang, invoice_template=tmpl) - _switch_to_user_db("harry-tuttle.db") - reg = app_db.get_user_by_db_file("harry-tuttle.db") - return {"ok": True, "data": _serialise(reg) if reg else None, "error": None} - - # -- DB lifecycle ------------------------------------------------------- - if method == "db.ensure": - _ensure_db() - return {"ok": True, "data": None, "error": None} - - if method == "db.exists": - from tuttle.app.core.abstractions import get_active_db - - return {"ok": True, "data": get_active_db().exists(), "error": None} - - raise ValueError(f"Unknown method: {method}") - - -# --------------------------------------------------------------------------- -# Main loop -# --------------------------------------------------------------------------- - def main(): - """Read JSON-RPC requests from stdin, write responses to stdout.""" + from tuttle.app.core.dispatch import dispatch + logger.info("Tuttle RPC server starting…") for line in sys.stdin: @@ -1414,6 +33,7 @@ def main(): if not line: continue + req_id = None response: Dict[str, Any] try: request = json.loads(line) @@ -1421,13 +41,14 @@ def main(): method = request.get("method", "") params = request.get("params", {}) - result = _dispatch(method, params) + result = dispatch(method, params) + response = {"jsonrpc": "2.0", "id": req_id, "result": result} except Exception as exc: logger.exception(f"RPC error: {exc}") response = { "jsonrpc": "2.0", - "id": request.get("id") if "request" in dir() else None, + "id": req_id, "error": { "code": -32603, "message": str(exc), diff --git a/tuttle_tests/test_app_start.py b/tuttle_tests/test_app_start.py index 4b178168..6485074d 100644 --- a/tuttle_tests/test_app_start.py +++ b/tuttle_tests/test_app_start.py @@ -37,20 +37,28 @@ APP_MODULES = [ "tuttle.app.core.abstractions", "tuttle.app.core.database_storage_impl", + "tuttle.app.core.dispatch", "tuttle.app.core.formatting", "tuttle.app.core.intent_result", - "tuttle.app.contacts.intent", + "tuttle.app.core.rpc_utils", + "tuttle.app.auth.intent", + "tuttle.app.auth.data_source", "tuttle.app.clients.intent", + "tuttle.app.contacts.intent", "tuttle.app.contracts.intent", - "tuttle.app.projects.intent", - "tuttle.app.invoicing.intent", "tuttle.app.dashboard.intent", - "tuttle.app.timeline.intent", - "tuttle.app.tax.intent", - "tuttle.app.salary.intent", - "tuttle.app.auth.intent", - "tuttle.app.auth.data_source", + "tuttle.app.db.intent", + "tuttle.app.demo.intent", + "tuttle.app.invoicing.intent", + "tuttle.app.llm.intent", "tuttle.app.preferences.intent", + "tuttle.app.projects.intent", + "tuttle.app.salary.intent", + "tuttle.app.settings.intent", + "tuttle.app.tax.intent", + "tuttle.app.timeline.intent", + "tuttle.app.timetracking.intent", + "tuttle.app.users.intent", "tuttle.rpc_server", ] diff --git a/tuttle_tests/test_rpc_dispatch.py b/tuttle_tests/test_rpc_dispatch.py new file mode 100644 index 00000000..c0f036dc --- /dev/null +++ b/tuttle_tests/test_rpc_dispatch.py @@ -0,0 +1,230 @@ +"""Integration tests for the RPC dispatch round-trip. + +Exercises the real code path the Electron shell uses: + + method string -> dispatch() -> intent -> DB -> to_rpc_dict()/dump() -> JSON + +Catches detached-instance errors, missing modules, serialisation bugs, and +data-shape mismatches between the Python core and the frontend. +""" + +import json +from pathlib import Path + +import pytest + +import tuttle.app.core.abstractions as abstractions +import tuttle.app_db as app_db_mod +from tuttle.app.core.dispatch import dispatch, _intents +from tuttle.app.core.rpc_utils import reset_all + + +# --------------------------------------------------------------------------- +# Fixture: isolated temp database with demo data +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def rpc_env(tmp_path_factory): + """Set up an isolated ~/.tuttle with full demo data, return the temp dir.""" + tmp = tmp_path_factory.mktemp("tuttle_rpc") + + orig_app_init = app_db_mod.AppDatabase.__init__ + + def _patched_init(self, app_dir=None): + orig_app_init(self, app_dir=tmp) + + app_db_mod.AppDatabase.__init__ = _patched_init + abstractions._active_db_path = tmp / "tuttle.db" + + try: + result = dispatch("db.ensure", {}) + assert result["ok"], f"db.ensure failed: {result}" + yield tmp + finally: + app_db_mod.AppDatabase.__init__ = orig_app_init + abstractions._active_db_path = Path.home() / ".tuttle" / "tuttle.db" + reset_all() + _intents.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def assert_ok(result: dict) -> dict: + """Assert the envelope is a successful {ok, data, error} dict.""" + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + assert "ok" in result and "data" in result and "error" in result + assert result["ok"] is True, f"RPC failed: {result.get('error')}" + assert result["error"] is None + json.dumps(result) + return result + + +# --------------------------------------------------------------------------- +# 1. Boot lifecycle +# --------------------------------------------------------------------------- + + +class TestLifecycle: + """The startup sequence the Electron shell runs on every launch.""" + + def test_db_ensure(self, rpc_env): + result = dispatch("db.ensure", {}) + assert_ok(result) + + def test_users_list(self, rpc_env): + result = dispatch("users.list", {}) + data = assert_ok(result)["data"] + assert isinstance(data, list) + assert len(data) >= 1 + demo = next((u for u in data if u.get("is_demo")), None) + assert demo is not None, "Demo user missing from users.list" + assert demo["db_file"] == "harry-tuttle.db" + + def test_users_get_active(self, rpc_env): + result = dispatch("users.get_active", {}) + data = assert_ok(result)["data"] + assert data is not None, "get_active returned None" + assert "name" in data + assert "db_file" in data + assert "is_demo" in data + assert "profile" in data + + def test_users_get_active_profile_shape(self, rpc_env): + data = dispatch("users.get_active", {})["data"] + profile = data["profile"] + assert profile is not None, "Demo user should have a profile" + assert "name" in profile + assert "email" in profile + assert "address" in profile + assert isinstance(profile["address"], dict) + + +# --------------------------------------------------------------------------- +# 2. Read-only route resolution — every frontend RPC method that fetches data +# --------------------------------------------------------------------------- + +READ_ROUTES = [ + "db.ensure", + "users.list", + "users.get_active", + "projects.get_all", + "projects.get_all_contracts", + "contracts.get_all", + "contracts.get_all_clients", + "clients.get_all", + "clients.get_all_contacts", + "contacts.get_all", + "invoicing.get_all", + "invoicing.available_templates", + "invoicing.available_languages", + "preferences.get", + "llm.get_config", + "timetracking.get_summary", + "timeline.get_events", +] + + +@pytest.mark.parametrize("method", READ_ROUTES) +def test_read_route_resolves(rpc_env, method): + """Every read route returns a valid {ok, data, error} envelope.""" + result = dispatch(method, {}) + assert_ok(result) + + +DASHBOARD_ROUTES = [ + ("dashboard.get_kpis", {}), + ("dashboard.get_monthly_chart_data", {"n_months": 12}), +] + + +@pytest.mark.parametrize("method,params", DASHBOARD_ROUTES) +def test_dashboard_routes(rpc_env, method, params): + result = dispatch(method, params) + assert_ok(result) + + +# --------------------------------------------------------------------------- +# 3. Serialization: relationship data must be present (not just FK ids) +# --------------------------------------------------------------------------- + + +class TestSerialization: + """Entities with __rpc_relationships__ must include expanded relationships.""" + + def test_projects_include_contract(self, rpc_env): + data = dispatch("projects.get_all", {})["data"] + assert isinstance(data, list) and len(data) > 0 + project = data[0] + assert "contract" in project, "Project missing 'contract' relationship" + assert isinstance(project["contract"], dict) + assert "id" in project["contract"] + + def test_contracts_include_client(self, rpc_env): + data = dispatch("contracts.get_all", {})["data"] + assert isinstance(data, list) and len(data) > 0 + contract = data[0] + assert "client" in contract, "Contract missing 'client' relationship" + assert isinstance(contract["client"], dict) + + def test_contracts_include_projects(self, rpc_env): + data = dispatch("contracts.get_all", {})["data"] + contract = data[0] + assert "projects" in contract, "Contract missing 'projects' relationship" + assert isinstance(contract["projects"], list) + + def test_contracts_include_invoices(self, rpc_env): + data = dispatch("contracts.get_all", {})["data"] + contract = data[0] + assert "invoices" in contract, "Contract missing 'invoices' relationship" + assert isinstance(contract["invoices"], list) + + def test_clients_include_invoicing_contact(self, rpc_env): + data = dispatch("clients.get_all", {})["data"] + assert isinstance(data, list) and len(data) > 0 + client = data[0] + assert "invoicing_contact" in client, "Client missing 'invoicing_contact'" + assert isinstance(client["invoicing_contact"], dict) + + def test_contacts_include_address(self, rpc_env): + data = dispatch("contacts.get_all", {})["data"] + assert isinstance(data, list) and len(data) > 0 + contact = data[0] + assert "address" in contact, "Contact missing 'address' relationship" + assert isinstance(contact["address"], dict) + + def test_invoices_include_items(self, rpc_env): + data = dispatch("invoicing.get_all", {})["data"] + assert isinstance(data, list) and len(data) > 0 + invoice = data[0] + assert "items" in invoice, "Invoice missing 'items' relationship" + assert isinstance(invoice["items"], list) + + def test_invoices_include_contract(self, rpc_env): + data = dispatch("invoicing.get_all", {})["data"] + invoice = data[0] + assert "contract" in invoice, "Invoice missing 'contract' relationship" + assert isinstance(invoice["contract"], dict) + + def test_invoices_computed_properties(self, rpc_env): + data = dispatch("invoicing.get_all", {})["data"] + invoice = data[0] + for prop in ("sum", "total", "status", "due_date"): + assert prop in invoice, f"Invoice missing computed property '{prop}'" + + def test_full_response_is_json_serializable(self, rpc_env): + for method in [ + "projects.get_all", + "contracts.get_all", + "clients.get_all", + "contacts.get_all", + "invoicing.get_all", + ]: + result = dispatch(method, {}) + try: + json.dumps(result) + except (TypeError, ValueError) as exc: + pytest.fail(f"{method} response not JSON-serializable: {exc}") From f4cf957ec6046a65dd7ae640f9b323a83788a032 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Tue, 12 May 2026 20:36:54 +0200 Subject: [PATCH 2/9] =?UTF-8?q?Enhance=20justfile=20for=20Electron=20app?= =?UTF-8?q?=20packaging=20and=20beta=20distribution.=20+=20Add=20target=20?= =?UTF-8?q?parameter=20to=20pack=20and=20build=20tasks=20for=20flexibility?= =?UTF-8?q?.=20+=20Implement=20beta=20task=20to=20create=20a=20.zip=20with?= =?UTF-8?q?=20install=20script=20for=20Tuttle=20app.=20+=20Include=20code?= =?UTF-8?q?=20signing=20for=20binaries=20and=20cleanup=20of=20staging=20di?= =?UTF-8?q?rectory.=20=E2=88=B4=20Streamlines=20packaging=20process=20and?= =?UTF-8?q?=20improves=20user=20installation=20experience.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- justfile | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/justfile b/justfile index 62e8eb24..f623d493 100644 --- a/justfile +++ b/justfile @@ -29,15 +29,43 @@ clean-app: rm -rf "{{electron}}/release" # Package the Electron .app (requires build-sidecar + build-renderer first) -pack: - cd {{electron}} && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --dir +pack target="dir": + cd {{electron}} && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac {{target}} @echo "Ad-hoc signing all binaries…" find "{{app}}" -type f \( -name '*.dylib' -o -name '*.so' -o -perm +111 \) -exec codesign --force --sign - {} \; 2>/dev/null || true codesign --force --deep --sign - "{{app}}" -# Full build: sidecar → renderer → .app -build: clean-app build-sidecar build-renderer pack - @echo "✓ {{app}}" +# Full build: sidecar → renderer → package (pass "dmg" for .dmg) +build target="dir": clean-app build-sidecar build-renderer (pack target) + @echo "✓ {{electron}}/release/" + +# Create a beta .zip with an install script that strips quarantine +beta: (build "dir") + #!/usr/bin/env bash + set -euo pipefail + staging="{{electron}}/release/beta" + rm -rf "$staging" + mkdir -p "$staging" + cp -R "{{app}}" "$staging/" + cat > "$staging/Install Tuttle.command" << 'SCRIPT' + #!/bin/bash + set -e + cd "$(dirname "$0")" + dest="/Applications/Tuttle.app" + [ -d "$dest" ] && rm -rf "$dest" + cp -R "Tuttle.app" "$dest" + xattr -cr "$dest" + echo "" + echo "✓ Tuttle installed to /Applications" + echo " You can now open it from Launchpad or Spotlight." + echo "" + read -n1 -rsp "Press any key to close…" + SCRIPT + chmod +x "$staging/Install Tuttle.command" + cd "{{electron}}/release" + zip -ry "Tuttle-beta.zip" beta/ + rm -rf "$staging" + echo "✓ {{electron}}/release/Tuttle-beta.zip" # ── Run ───────────────────────────────────────────────────────────────────── From e334e3231062fe2af192cf5ddb110481e9dea214 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Tue, 12 May 2026 21:12:04 +0200 Subject: [PATCH 3/9] Refactor invoice templates to enhance address handling. + Updated `invoice.html` files across multiple templates to conditionally display invoicing contact and recipient address. + Implemented checks for `invoicing_contact` and `invoice_recipient_address` to improve rendering logic. + Enhanced user experience by ensuring accurate address representation in invoices. --- templates/invoice-anvil/invoice.html | 8 +- templates/invoice-classic/invoice.html | 6 +- templates/invoice-minimal/invoice.html | 10 +- templates/invoice-modern/invoice.html | 10 +- templates/invoice/invoice.html | 9 +- templates/timesheet-anvil/timesheet.html | 9 +- .../src/components/business/ClientsView.tsx | 84 +++++++++++---- tuttle/app/clients/intent.py | 8 +- tuttle/app/invoicing/intent.py | 15 ++- tuttle/app/users/intent.py | 8 +- tuttle/demo.py | 102 +++++++++++++++--- tuttle/invoicing.py | 19 +++- tuttle/model.py | 37 ++++++- tuttle_tests/conftest.py | 19 ++-- tuttle_tests/demo.py | 32 +++--- tuttle_tests/test_dashboard.py | 12 ++- tuttle_tests/test_demo.py | 12 ++- tuttle_tests/test_model.py | 48 ++++++++- tuttle_tests/test_rpc_dispatch.py | 8 +- tuttle_tests/test_tax.py | 12 ++- 20 files changed, 374 insertions(+), 94 deletions(-) diff --git a/templates/invoice-anvil/invoice.html b/templates/invoice-anvil/invoice.html index dfb2b932..6eb00bea 100644 --- a/templates/invoice-anvil/invoice.html +++ b/templates/invoice-anvil/invoice.html @@ -38,8 +38,12 @@ {{ invoice.contract.client.name }}
- c/o {{ invoice.contract.client.invoicing_contact.name}}
- {{ invoice.contract.client.invoicing_contact.address.html }} + {% if invoice.contract.client.invoicing_contact %} + c/o {{ invoice.contract.client.invoicing_contact.name }}
+ {% endif %} + {% if invoice.contract.client.invoice_recipient_address %} + {{ invoice.contract.client.invoice_recipient_address.html }} + {% endif %} {{ user.name }}
diff --git a/templates/invoice-classic/invoice.html b/templates/invoice-classic/invoice.html index 0d1dd81e..03795726 100644 --- a/templates/invoice-classic/invoice.html +++ b/templates/invoice-classic/invoice.html @@ -51,8 +51,12 @@ Bill To
{{ invoice.contract.client.name }}
+ {% if invoice.contract.client.invoicing_contact %} c/o {{ invoice.contract.client.invoicing_contact.name }}
- {{ invoice.contract.client.invoicing_contact.address.html }} + {% endif %} + {% if invoice.contract.client.invoice_recipient_address %} + {{ invoice.contract.client.invoice_recipient_address.html }} + {% endif %}
diff --git a/templates/invoice-minimal/invoice.html b/templates/invoice-minimal/invoice.html index 134d92ae..88a5716b 100644 --- a/templates/invoice-minimal/invoice.html +++ b/templates/invoice-minimal/invoice.html @@ -16,10 +16,14 @@
{{ user.name }} · {{ user.address.street }} {{ user.address.number }} · {{ user.address.postal_code }} {{ user.address.city }}
{{ invoice.contract.client.name }}
+ {% if invoice.contract.client.invoicing_contact %}
c/o {{ invoice.contract.client.invoicing_contact.name }}
-
{{ invoice.contract.client.invoicing_contact.address.street }} {{ invoice.contract.client.invoicing_contact.address.number }}
-
{{ invoice.contract.client.invoicing_contact.address.postal_code }} {{ invoice.contract.client.invoicing_contact.address.city }}
-
{{ invoice.contract.client.invoicing_contact.address.country }}
+ {% endif %} + {% if invoice.contract.client.invoice_recipient_address %} +
{{ invoice.contract.client.invoice_recipient_address.street }} {{ invoice.contract.client.invoice_recipient_address.number }}
+
{{ invoice.contract.client.invoice_recipient_address.postal_code }} {{ invoice.contract.client.invoice_recipient_address.city }}
+
{{ invoice.contract.client.invoice_recipient_address.country }}
+ {% endif %}
diff --git a/templates/invoice-modern/invoice.html b/templates/invoice-modern/invoice.html index 5aab7338..8b3e49c5 100644 --- a/templates/invoice-modern/invoice.html +++ b/templates/invoice-modern/invoice.html @@ -16,10 +16,14 @@
{{ user.name }} · {{ user.address.street }} {{ user.address.number }} · {{ user.address.postal_code }} {{ user.address.city }}
{{ invoice.contract.client.name }}
+ {% if invoice.contract.client.invoicing_contact %}
c/o {{ invoice.contract.client.invoicing_contact.name }}
-
{{ invoice.contract.client.invoicing_contact.address.street }} {{ invoice.contract.client.invoicing_contact.address.number }}
-
{{ invoice.contract.client.invoicing_contact.address.postal_code }} {{ invoice.contract.client.invoicing_contact.address.city }}
-
{{ invoice.contract.client.invoicing_contact.address.country }}
+ {% endif %} + {% if invoice.contract.client.invoice_recipient_address %} +
{{ invoice.contract.client.invoice_recipient_address.street }} {{ invoice.contract.client.invoice_recipient_address.number }}
+
{{ invoice.contract.client.invoice_recipient_address.postal_code }} {{ invoice.contract.client.invoice_recipient_address.city }}
+
{{ invoice.contract.client.invoice_recipient_address.country }}
+ {% endif %}
diff --git a/templates/invoice/invoice.html b/templates/invoice/invoice.html index ebf1593b..8038b484 100644 --- a/templates/invoice/invoice.html +++ b/templates/invoice/invoice.html @@ -36,8 +36,13 @@

Invoice No. {{ invoice.number }}

- {{ invoice.contract.client.invoicing_contact.name}}
- {{ invoice.contract.client.invoicing_contact.address.html }} + {{ invoice.contract.client.name }}
+ {% if invoice.contract.client.invoicing_contact %} + c/o {{ invoice.contract.client.invoicing_contact.name }}
+ {% endif %} + {% if invoice.contract.client.invoice_recipient_address %} + {{ invoice.contract.client.invoice_recipient_address.html }} + {% endif %}

diff --git a/templates/timesheet-anvil/timesheet.html b/templates/timesheet-anvil/timesheet.html index acb5a38d..eda97bef 100644 --- a/templates/timesheet-anvil/timesheet.html +++ b/templates/timesheet-anvil/timesheet.html @@ -37,8 +37,13 @@
- {{ timesheet.project.client.invoicing_contact.name}}
- {{ timesheet.project.client.invoicing_contact.address.html }} + {{ timesheet.project.client.name }}
+ {% if timesheet.project.client.invoicing_contact %} + c/o {{ timesheet.project.client.invoicing_contact.name }}
+ {% endif %} + {% if timesheet.project.client.invoice_recipient_address %} + {{ timesheet.project.client.invoice_recipient_address.html }} + {% endif %}
{{ user.name }}
diff --git a/tuttle-electron/src/components/business/ClientsView.tsx b/tuttle-electron/src/components/business/ClientsView.tsx index 93f508e4..90ad1515 100644 --- a/tuttle-electron/src/components/business/ClientsView.tsx +++ b/tuttle-electron/src/components/business/ClientsView.tsx @@ -53,6 +53,9 @@ export function ClientsView() { invoicing_contact: data.contactId ? { id: data.contactId } : undefined, + address: (data.street || data.number || data.city || data.postalCode || data.country) + ? { street: data.street, number: data.number, city: data.city, postal_code: data.postalCode, country: data.country } + : undefined, }; if (mode === "edit" && selected) { client.id = selected.id; @@ -217,11 +220,19 @@ function ClientDetail({ client, onEdit, onDelete, deleteError }: { const contactName = ic ? displayName(ic) : ""; const email = ic ? str(ic, "email") : ""; const company = ic ? str(ic, "company") : ""; - const addr = ic ? subEntity(ic, "address") : null; - const addrParts = addr ? [ - [str(addr, "street"), str(addr, "number")].filter(Boolean).join(" "), - [str(addr, "postal_code"), str(addr, "city")].filter(Boolean).join(" "), - str(addr, "country"), + + const clientAddr = subEntity(client, "address"); + const clientAddrParts = clientAddr ? [ + [str(clientAddr, "street"), str(clientAddr, "number")].filter(Boolean).join(" "), + [str(clientAddr, "postal_code"), str(clientAddr, "city")].filter(Boolean).join(" "), + str(clientAddr, "country"), + ].filter(Boolean) : []; + + const contactAddr = ic ? subEntity(ic, "address") : null; + const contactAddrParts = contactAddr ? [ + [str(contactAddr, "street"), str(contactAddr, "number")].filter(Boolean).join(" "), + [str(contactAddr, "postal_code"), str(contactAddr, "city")].filter(Boolean).join(" "), + str(contactAddr, "country"), ].filter(Boolean) : []; return ( @@ -250,21 +261,35 @@ function ClientDetail({ client, onEdit, onDelete, deleteError }: {
{deleteError}
)} -
-
Invoicing Contact
- {contactName && } label="Name" value={contactName} />} - {email && } label="Email" value={email} />} - {company && } label="Company" value={company} />} - {addrParts.length > 0 && ( + {clientAddrParts.length > 0 && ( +
+
Address
-
Address
- {addrParts.map((line, i) =>
{line}
)} + {clientAddrParts.map((line, i) =>
{line}
)}
- )} -
+
+ )} + + {(contactName || email || company || contactAddrParts.length > 0) && ( +
+
Invoicing Contact
+ {contactName && } label="Name" value={contactName} />} + {email && } label="Email" value={email} />} + {company && } label="Company" value={company} />} + {contactAddrParts.length > 0 && ( +
+ +
+
Address
+ {contactAddrParts.map((line, i) =>
{line}
)} +
+
+ )} +
+ )} ); } @@ -286,6 +311,11 @@ function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; interface ClientFormData { name: string; contactId: number | null; + street: string; + number: string; + city: string; + postalCode: string; + country: string; } function ClientForm({ client, contacts, onSave, onCancel }: { @@ -295,8 +325,14 @@ function ClientForm({ client, contacts, onSave, onCancel }: { onCancel: () => void; }) { const ic = client ? subEntity(client, "invoicing_contact") : null; + const addr = client ? subEntity(client, "address") : null; const [name, setName] = useState(client ? str(client, "name") : ""); const [contactId, setContactId] = useState(ic?.id ?? null); + const [street, setStreet] = useState(addr ? str(addr, "street") : ""); + const [number, setNumber] = useState(addr ? str(addr, "number") : ""); + const [city, setCity] = useState(addr ? str(addr, "city") : ""); + const [postalCode, setPostalCode] = useState(addr ? str(addr, "postal_code") : ""); + const [country, setCountry] = useState(addr ? str(addr, "country") : ""); const [saving, setSaving] = useState(false); const isNew = !client; @@ -305,7 +341,7 @@ function ClientForm({ client, contacts, onSave, onCancel }: { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSaving(true); - await onSave({ name, contactId }); + await onSave({ name, contactId, street, number, city, postalCode, country }); setSaving(false); } @@ -329,11 +365,23 @@ function ClientForm({ client, contacts, onSave, onCancel }: { -
+
+
+ + + + +
+
+ +
+
+ +
{ const f = e.target.files?.[0]; if (f) onFileSelected(f); }} /> + + )} + + {parsing && ( +
+ + Analyzing document with AI... +
+ )} + + {parseError && ( +
+ {parseError} +
+ )} + + ); +} + +// --------------------------------------------------------------------------- +// Committed Phase +// --------------------------------------------------------------------------- + +function CommittedPhase({ result, onDone }: { + result: Record; onDone: () => void; +}) { + const created = result.created || []; + const linked = result.linked || []; + const updated = result.updated || []; + const total = created.length + linked.length + updated.length; + + return ( +
+
+ +
+

Import Complete

+

{total} entities processed.

+ +
+ {created.length > 0 && ( +
+
Created
+ {created.map((s, i) =>
{s}
)} +
+ )} + {linked.length > 0 && ( +
+
Linked to existing
+ {linked.map((s, i) =>
{s}
)} +
+ )} + {updated.length > 0 && ( +
+
Updated
+ {updated.map((s, i) =>
{s}
)} +
+ )} +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Generic Entity Section +// --------------------------------------------------------------------------- + +function EntitySection({ icon, title, items, setItems, existing, matchFields, renderCard }: { + icon: React.ReactNode; + title: string; + items: ImportEntity[]; + setItems: React.Dispatch[]>>; + existing: ExistingEntity[]; + matchFields: string[]; + renderCard: (item: ImportEntity, idx: number, update: (idx: number, item: ImportEntity) => void) => React.ReactNode; +}) { + const [collapsed, setCollapsed] = useState(false); + if (items.length === 0) return null; + + const acceptedCount = items.filter((i) => i.status === "accepted").length; + + function update(idx: number, item: ImportEntity) { + setItems((prev) => prev.map((p, i) => i === idx ? item : p)); + } + + function toggleStatus(idx: number) { + setItems((prev) => prev.map((p, i) => + i === idx ? { ...p, status: p.status === "accepted" ? "discarded" : "accepted" } : p + )); + } + + function acceptAll() { + setItems((prev) => prev.map((p) => ({ ...p, status: "accepted" }))); + } + + return ( +
+ + )} + + + {!collapsed && ( +
+ {items.map((item, idx) => ( +
+
+ update(idx, { + ...item, + matchedExistingId: id || undefined, + existingData: id ? existing.find((e) => e.id === id) : undefined, + })} + /> +
+ +
+
+ {item.status === "accepted" && renderCard(item, idx, update)} +
+ ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Match Badge + Dropdown +// --------------------------------------------------------------------------- + +function MatchBadge({ item, existing, onChangeMatch }: { + item: ImportEntity; + existing: ExistingEntity[]; + onChangeMatch: (id: number | null) => void; +}) { + const [open, setOpen] = useState(false); + const matched = item.matchedExistingId; + const matchedEntity = matched ? existing.find((e) => e.id === matched) : null; + const matchLabel = matchedEntity + ? (matchedEntity.name || matchedEntity.title || `${matchedEntity.first_name} ${matchedEntity.last_name}` || `#${matchedEntity.id}`) as string + : null; + + return ( +
+ + + {open && ( +
+ + {existing.map((e) => { + const label = (e.name || e.title || `${e.first_name || ""} ${e.last_name || ""}`.trim() || `#${e.id}`) as string; + return ( + + ); + })} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Entity Cards +// --------------------------------------------------------------------------- + +function ContactCard({ item, onUpdate, existing }: { + item: ImportEntity; + onUpdate: (u: ImportEntity) => void; + existing: ExistingEntity[]; +}) { + const d = item.data; + const ex = item.existingData; + + function set(field: keyof ParsedContact, value: string) { + onUpdate({ ...item, data: { ...d, [field]: value } }); + } + function setAddr(field: string, value: string) { + onUpdate({ ...item, data: { ...d, address: { ...d.address, [field]: value } } }); + } + + return ( +
+
+ set("first_name", v)} /> + set("last_name", v)} /> + set("company", v)} /> + set("email", v)} /> +
+
+ setAddr("street", v)} /> + setAddr("number", v)} /> + setAddr("city", v)} /> + setAddr("postal_code", v)} /> + setAddr("country", v)} /> +
+
+ ); +} + +function ClientCard({ item, onUpdate, existing, contacts }: { + item: ImportEntity; + onUpdate: (u: ImportEntity) => void; + existing: ExistingEntity[]; + contacts: ImportEntity[]; +}) { + const d = item.data; + const ex = item.existingData; + const acceptedContacts = contacts.filter((c) => c.status === "accepted"); + + function set(field: keyof ParsedClient, value: string) { + onUpdate({ ...item, data: { ...d, [field]: value } }); + } + + return ( +
+
+ set("name", v)} /> +
+ ({ + ref: c.data.ref, + label: `${c.data.first_name} ${c.data.last_name}`.trim() || c.data.company || c.data.ref, + }))} + onChange={(ref) => set("contact_ref", ref)} + hint={d.contact_ref} + /> +
+ ); +} + +function ContractCard({ item, onUpdate, existing, clients }: { + item: ImportEntity; + onUpdate: (u: ImportEntity) => void; + existing: ExistingEntity[]; + clients: ImportEntity[]; +}) { + const d = item.data; + const ex = item.existingData; + const acceptedClients = clients.filter((c) => c.status === "accepted"); + + function set(field: K, value: ParsedContract[K]) { + onUpdate({ ...item, data: { ...d, [field]: value } }); + } + + return ( +
+
+ set("title", v)} /> + set("rate", v ? parseFloat(v) : null)} /> + set("currency", v)} /> + set("unit", v)} /> + set("billing_cycle", v)} /> + set("volume", v ? parseInt(v) : null)} /> +
+
+ set("signature_date", v)} type="date" /> + set("start_date", v)} type="date" /> + set("end_date", v)} type="date" /> +
+
+ set("VAT_rate", v ? parseFloat(v) : null)} /> + set("term_of_payment", v ? parseInt(v) : null)} /> +
+ ({ + ref: c.data.ref, + label: c.data.name || c.data.ref, + }))} + onChange={(ref) => set("client_ref", ref)} + hint={d.client_ref} + /> +
+ ); +} + +function ProjectCard({ item, onUpdate, existing, importedContracts }: { + item: ImportEntity; + onUpdate: (u: ImportEntity) => void; + existing: ExistingEntity[]; + importedContracts: ImportEntity[]; +}) { + const d = item.data; + const ex = item.existingData; + const acceptedContracts = importedContracts.filter((c) => c.status === "accepted"); + + function set(field: K, value: ParsedProject[K]) { + onUpdate({ ...item, data: { ...d, [field]: value } }); + } + + return ( +
+
+ set("title", v)} /> + set("tag", v)} /> +
+ set("description", v)} /> +
+ set("start_date", v)} type="date" /> + set("end_date", v)} type="date" /> +
+ ({ + ref: c.data.ref, + label: c.data.title || c.data.ref, + }))} + onChange={(ref) => set("contract_ref", ref)} + hint={d.contract_ref} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Shared UI primitives +// --------------------------------------------------------------------------- + +function AiField({ label, value, dbValue, onChange, type = "text" }: { + label: string; value: string; dbValue?: string; + onChange: (v: string) => void; type?: string; +}) { + const differs = dbValue != null && dbValue !== "" && value !== dbValue; + + return ( +
+ + onChange(e.target.value)} + className={`w-full px-2.5 py-1.5 rounded-md text-sm bg-bg-card text-primary outline-none + focus:border-fuchsia-400 transition-colors placeholder:text-muted border ${ + differs ? "border-amber-400/60" : "border-fuchsia-400/30" + }`} /> + {differs && ( +
+ DB: {dbValue} +
+ )} +
+ ); +} + +function RefDropdown({ label, currentRef, options, onChange, hint }: { + label: string; + currentRef: string; + options: { ref: string; label: string }[]; + onChange: (ref: string) => void; + hint?: string; +}) { + const noLink = !currentRef || !options.some((o) => o.ref === currentRef); + + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function autoMatch( + data: T, + existingList: ExistingEntity[] | undefined, + ...matchKeys: string[] +): ImportEntity { + const entity: ImportEntity = { data, status: "accepted" }; + if (!existingList) return entity; + + for (const ex of existingList) { + const d = data as unknown as Record; + const matches = matchKeys.every((k) => { + const a = String(d[k] || "").toLowerCase().trim(); + const b = String(ex[k] || "").toLowerCase().trim(); + return a && b && a === b; + }); + if (matches) { + entity.matchedExistingId = ex.id; + entity.existingData = ex as Record; + break; + } + } + return entity; +} + +function accepted(items: ImportEntity[]): ImportEntity[] { + return items.filter((i) => i.status === "accepted"); +} + +function commitShape(item: ImportEntity): Record { + const d: Record = { ...item.data }; + if (item.matchedExistingId) { + d.existing_id = item.matchedExistingId; + d.update_existing = true; + } + return d; +} diff --git a/tuttle-electron/src/components/layout/Shell.tsx b/tuttle-electron/src/components/layout/Shell.tsx index 0af94f43..bd534106 100644 --- a/tuttle-electron/src/components/layout/Shell.tsx +++ b/tuttle-electron/src/components/layout/Shell.tsx @@ -12,6 +12,7 @@ import { TimelineView } from "../timeline/TimelineView"; import { TaxReservesView } from "../tax/TaxReservesView"; import { SalaryView } from "../salary/SalaryView"; import { TimeTrackingView } from "../timetracking/TimeTrackingView"; +import { ContractImportView } from "../import/ContractImportView"; import { PlaceholderView } from "../shared/PlaceholderView"; import { NavigationContext, type NavigationFilter } from "../shared/NavigationContext"; import { rpc } from "../../api/rpc"; @@ -208,6 +209,7 @@ function DetailView({ id }: { id: string }) { case "contacts": return ; case "timetracking": return ; case "invoicing": return ; + case "import": return ; case "settings": return ; default: return ; } diff --git a/tuttle-electron/src/components/layout/Sidebar.tsx b/tuttle-electron/src/components/layout/Sidebar.tsx index 57d82b3a..74842097 100644 --- a/tuttle-electron/src/components/layout/Sidebar.tsx +++ b/tuttle-electron/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { LayoutDashboard, CalendarDays, PieChart, Banknote, FolderKanban, FileSignature, Building2, Users, Clock, FileText, - Settings, ChevronUp, UserPlus, Trash2, + FileUp, Settings, ChevronUp, UserPlus, Trash2, type LucideIcon, } from "lucide-react"; @@ -32,6 +32,7 @@ const SECTIONS: { label: string; items: SidebarItem[] }[] = [ items: [ { id: "timetracking", label: "Time Tracking", icon: Clock }, { id: "invoicing", label: "Invoicing", icon: FileText }, + { id: "import", label: "Import", icon: FileUp }, ], }, { diff --git a/tuttle/app/imports/intent.py b/tuttle/app/imports/intent.py new file mode 100644 index 00000000..1e460f62 --- /dev/null +++ b/tuttle/app/imports/intent.py @@ -0,0 +1,188 @@ +"""Batch-commit workflow for document-imported entity graphs.""" + +import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional + +import sqlmodel +from loguru import logger + +from ..core.abstractions import SQLModelDataSourceMixin +from ..core.intent_result import IntentResult +from ...model import Address, Contact, Client, Contract, Project +from ...time import Cycle, TimeUnit + + +# Fields that are internal to the import workflow, not part of the model +_IMPORT_META = { + "ref", + "existing_id", + "update_existing", + "contact_ref", + "client_ref", + "contract_ref", +} + + +class ImportsIntent(SQLModelDataSourceMixin): + """Commit a reviewed set of extracted entities in dependency order.""" + + def __init__(self): + super().__init__() + + def get_existing_entities(self) -> IntentResult: + """Return all contacts, clients, contracts, projects for smart-matching.""" + try: + return IntentResult( + was_intent_successful=True, + data={ + "contacts": [c.to_rpc_dict() for c in self.query(Contact)], + "clients": [c.to_rpc_dict() for c in self.query(Client)], + "contracts": [c.to_rpc_dict() for c in self.query(Contract)], + "projects": [p.to_rpc_dict() for p in self.query(Project)], + }, + ) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load existing entities for matching.", + log_message=f"ImportsIntent.get_existing_entities: {e}", + exception=e, + ) + + def commit_contract_import(self, data: dict) -> IntentResult: + """Persist the finalized import graph transactionally. + + Each entity list item has either ``existing_id`` (link/update) or + not (create new). Cross-references use ``ref`` strings resolved + via ``contact_ref``, ``client_ref``, ``contract_ref``. + """ + try: + ref_to_id: Dict[str, int] = {} + summary: Dict[str, List[str]] = {"created": [], "linked": [], "updated": []} + + with self.create_session() as session: + for item in data.get("contacts", []): + _save_entity( + session, + Contact, + item, + ref_to_id, + summary, + nested={"address": Address}, + ) + + for item in data.get("clients", []): + _save_entity( + session, + Client, + item, + ref_to_id, + summary, + ref_fks={"contact_ref": "invoicing_contact_id"}, + ) + + for item in data.get("contracts", []): + _save_entity( + session, + Contract, + item, + ref_to_id, + summary, + ref_fks={"client_ref": "client_id"}, + ) + + for item in data.get("projects", []): + _save_entity( + session, + Project, + item, + ref_to_id, + summary, + ref_fks={"contract_ref": "contract_id"}, + ) + + session.commit() + + return IntentResult(was_intent_successful=True, data=summary) + except Exception as e: + logger.exception("commit_contract_import failed") + return IntentResult( + was_intent_successful=False, + error_msg=f"Import failed: {e}", + log_message=f"ImportsIntent.commit_contract_import: {e}", + exception=e, + ) + + +def _save_entity( + session: sqlmodel.Session, + model_cls: type, + item: dict, + ref_to_id: Dict[str, int], + summary: Dict[str, List[str]], + *, + ref_fks: Optional[Dict[str, str]] = None, + nested: Optional[Dict[str, type]] = None, +): + """Create or link a single entity, resolving cross-refs to FK ids.""" + ref = item.get("ref", "") + existing_id = item.get("existing_id") + label = _entity_label(model_cls, item) + + if existing_id: + entity = session.get(model_cls, existing_id) + if entity and item.get("update_existing"): + fields = _model_fields(item, model_cls) + for k, v in fields.items(): + setattr(entity, k, v) + summary["updated"].append(label) + else: + summary["linked"].append(label) + if entity: + _bind_ref(ref, entity.id, ref_to_id) + return + + # Resolve cross-ref strings to FK ids + fields = dict(item) + for ref_key, fk_field in (ref_fks or {}).items(): + ref_val = fields.pop(ref_key, "") + if ref_val and ref_val in ref_to_id: + fields[fk_field] = ref_to_id[ref_val] + + # Handle nested objects (e.g. address) + nested_objects = {} + for rel_name, rel_cls in (nested or {}).items(): + rel_data = fields.pop(rel_name, None) + if isinstance(rel_data, dict): + rel_data.pop("id", None) + nested_objects[rel_name] = rel_cls(**rel_data) + + clean = _model_fields(fields, model_cls) + entity = model_cls(**clean, **nested_objects) + session.add(entity) + session.flush() + _bind_ref(ref, entity.id, ref_to_id) + summary["created"].append(label) + + +def _model_fields(data: dict, model_cls: type) -> dict: + """Filter a dict to only keys that are valid model fields, excluding meta.""" + valid = set(model_cls.model_fields.keys()) - {"id"} + return {k: v for k, v in data.items() if k in valid and k not in _IMPORT_META} + + +def _entity_label(model_cls: type, item: dict) -> str: + name = model_cls.__name__ + title = item.get("title") or item.get("name") or "" + if not title: + first = item.get("first_name", "") + last = item.get("last_name", "") + title = f"{first} {last}".strip() + return f"{name}: {title}" if title else name + + +def _bind_ref(ref: str, entity_id: Optional[int], ref_to_id: Dict[str, int]): + """Record the ref -> DB id mapping after flush.""" + if ref and entity_id is not None: + ref_to_id[ref] = entity_id diff --git a/tuttle/app/llm/intent.py b/tuttle/app/llm/intent.py index 2fe21145..a3cc5f7b 100644 --- a/tuttle/app/llm/intent.py +++ b/tuttle/app/llm/intent.py @@ -35,3 +35,15 @@ def parse_document( _llm.load_config(), ) return IntentResult(was_intent_successful=True, data=items) + + def parse_contract_document( + self, + file_base64: str, + file_name: str, + ) -> IntentResult: + result = _llm.parse_contract_document( + file_base64, + file_name, + _llm.load_config(), + ) + return IntentResult(was_intent_successful=True, data=result) diff --git a/tuttle/llm.py b/tuttle/llm.py index 083cd49a..876661e0 100644 --- a/tuttle/llm.py +++ b/tuttle/llm.py @@ -124,13 +124,13 @@ def _flat_schema(model_cls: type, *, include: Optional[List[str]] = None) -> typ _AddressExtract = _flat_schema(Address) +_ContactScalarExtract = _flat_schema( + Contact, include=["first_name", "last_name", "company", "email"] +) -class _ContactExtract(BaseModel): - first_name: Optional[str] = None - last_name: Optional[str] = None - company: Optional[str] = None - email: Optional[str] = None +# Contact needs a nested address object (not an FK), so extend the flat schema +class _ContactExtract(_ContactScalarExtract): # type: ignore[valid-type] address: Optional[_AddressExtract] = None # type: ignore[valid-type] @@ -409,6 +409,131 @@ def _map_projects(result: ProjectExtractionResult) -> List[Dict[str, Any]]: return results +# --------------------------------------------------------------------------- +# Unified contract-document extraction (all entity types in one pass) +# --------------------------------------------------------------------------- + + +class _RefContact(_ContactExtract): + ref: str = Field(description="Internal reference ID, e.g. 'contact_1'") + + +class _RefClient(_ClientExtract): # type: ignore[valid-type] + ref: str = Field(description="Internal reference ID, e.g. 'client_1'") + contact_ref: Optional[str] = Field( + default=None, description="ref of the contact person for this client" + ) + + +class _RefContract(_ContractExtract): # type: ignore[valid-type] + ref: str = Field(description="Internal reference ID, e.g. 'contract_1'") + client_ref: Optional[str] = Field( + default=None, description="ref of the client this contract belongs to" + ) + + +class _RefProject(_ProjectExtract): # type: ignore[valid-type] + ref: str = Field(description="Internal reference ID, e.g. 'project_1'") + contract_ref: Optional[str] = Field( + default=None, description="ref of the contract this project belongs to" + ) + + +class ContractDocumentExtractionResult(BaseModel): + """All entities extracted from a single contract document, with cross-refs.""" + + contacts: List[_RefContact] = [] + clients: List[_RefClient] = [] + contracts: List[_RefContract] = [] + projects: List[_RefProject] = [] + + +_CONTRACT_DOC_PROMPT = ( + "You are analysing a contract or service-agreement document for a freelancer. " + "Extract ALL of the following entity types that appear in the document:\n\n" + "1. **contacts** — people mentioned (e.g. signatories, contact persons). " + " Extract first_name, last_name, company, email, and address fields.\n" + "2. **clients** — companies or organisations that are the contracting party. " + " Extract the client name. If a contact person is associated, set contact_ref " + " to the ref of the corresponding contact.\n" + "3. **contracts** — the contractual agreements themselves. " + " Extract title, rate, currency, unit (hour/day), billing_cycle (monthly/quarterly/yearly), " + " volume, signature_date, start_date, end_date, VAT_rate, term_of_payment. " + " Set client_ref to the ref of the client.\n" + "4. **projects** — specific project descriptions or work packages. " + " Extract title, tag (starting with #), description, start_date, end_date. " + " Set contract_ref to the ref of the contract.\n\n" + "Assign each entity a unique ref string (e.g. contact_1, client_1, contract_1, project_1) " + "and use these refs to link related entities.\n" + "Dates must be in YYYY-MM-DD format. Leave unknown fields as null.\n\n" +) + + +def parse_contract_document( + file_base64: str, + file_name: str, + config: Optional[LLMConfig] = None, +) -> Dict[str, Any]: + """Extract all entity types from a contract document in a single LLM call. + + Returns a dict with keys: contacts, clients, contracts, projects — + each a list of dicts ready for frontend review. + """ + if config is None: + config = load_config() + + if not config.model: + raise ValueError("No LLM model configured. Please set up an LLM in Settings.") + + file_bytes = base64.b64decode(file_base64) + text = _extract_text(file_bytes, file_name) + + if not text.strip(): + raise ValueError("Document appears to be empty or could not be read.") + + llm = _get_llm(config) + sllm = llm.as_structured_llm(output_cls=ContractDocumentExtractionResult) + + prompt = ( + _CONTRACT_DOC_PROMPT + + "--- DOCUMENT START ---\n" + + text + + "\n--- DOCUMENT END ---" + ) + + response = sllm.complete(prompt) + extracted: ContractDocumentExtractionResult = response.raw + + return _map_contract_document(extracted) + + +def _dump_extracted(items: list) -> List[Dict[str, Any]]: + """Serialise a list of Pydantic extraction models to dicts. + + Coerces date fields via ``_serialise_date`` for frontend convenience. + """ + results = [] + for item in items: + d = item.model_dump() + for k in list(d): + if k.endswith("_date"): + d[k] = _serialise_date(d[k]) + results.append(d) + return results + + +def _map_contract_document( + result: ContractDocumentExtractionResult, +) -> Dict[str, Any]: + """Convert the unified extraction result to a frontend-ready dict.""" + return { + "contacts": _dump_extracted(result.contacts), + "clients": _dump_extracted(result.clients), + "contracts": _dump_extracted(result.contracts), + "projects": _dump_extracted(result.projects), + } + + # --------------------------------------------------------------------------- # Legacy function kept for backward compat (wraps parse_document) # --------------------------------------------------------------------------- From c06775f0ef9f329c454f1825268206cc7d9a2228 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 13 May 2026 11:27:30 +0200 Subject: [PATCH 5/9] =?UTF-8?q?Enhance=20development=20workflow=20and=20in?= =?UTF-8?q?voicing=20features.=20+=20Update=20justfile=20for=20Electron=20?= =?UTF-8?q?+=20Vite=20dev=20server=20with=20hot=20reload=20and=20live=20Py?= =?UTF-8?q?thon=20integration.=20+=20Modify=20`load`=20function=20in=20`In?= =?UTF-8?q?voicingView`=20to=20support=20optional=20`selectId`=20for=20inv?= =?UTF-8?q?oice=20selection.=20+=20Update=20`CreateInvoiceDialog`=20to=20h?= =?UTF-8?q?andle=20new=20invoice=20creation=20and=20pass=20new=20ID=20to?= =?UTF-8?q?=20`onCreated`=20callback.=20+=20Expose=20`readFile`=20and=20`p?= =?UTF-8?q?latform`=20in=20preload=20script=20for=20improved=20IPC=20funct?= =?UTF-8?q?ionality.=20=E2=88=B4=20Streamlines=20development=20process=20a?= =?UTF-8?q?nd=20enhances=20invoicing=20management.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- justfile | 12 ++++++++-- tuttle-electron/electron/preload.ts | 2 ++ .../components/invoicing/InvoicingView.tsx | 23 +++++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/justfile b/justfile index f623d493..836e6c26 100644 --- a/justfile +++ b/justfile @@ -10,9 +10,17 @@ app := electron / "release/mac-arm64/Tuttle.app" # ── Development ───────────────────────────────────────────────────────────── -# Vite dev server with hot reload (no packaged .app, no calendar access) +# Electron + Vite dev server (hot reload, live Python from .venv) dev: - cd {{electron}} && npx vite + #!/usr/bin/env bash + set -euo pipefail + cd "{{electron}}" + npm run build + npx vite & + VITE_PID=$! + sleep 2 + VITE_DEV_SERVER_URL=http://localhost:5173 npx electron . + kill $VITE_PID 2>/dev/null || true # ── Build ─────────────────────────────────────────────────────────────────── diff --git a/tuttle-electron/electron/preload.ts b/tuttle-electron/electron/preload.ts index 274abe9d..bf23a067 100644 --- a/tuttle-electron/electron/preload.ts +++ b/tuttle-electron/electron/preload.ts @@ -3,4 +3,6 @@ import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("tuttle", { rpc: (method: string, params: Record = {}) => ipcRenderer.invoke("rpc", method, params), + readFile: (filePath: string) => ipcRenderer.invoke("read-file", filePath), + platform: process.platform, }); diff --git a/tuttle-electron/src/components/invoicing/InvoicingView.tsx b/tuttle-electron/src/components/invoicing/InvoicingView.tsx index d454e79e..851eea17 100644 --- a/tuttle-electron/src/components/invoicing/InvoicingView.tsx +++ b/tuttle-electron/src/components/invoicing/InvoicingView.tsx @@ -45,10 +45,16 @@ export function InvoicingView() { useEffect(() => { load(); }, []); - async function load() { + async function load(selectId?: number) { setLoading(true); const res = await rpc("invoicing.get_all"); - if (res.ok && res.data) setInvoices(res.data); + if (res.ok && res.data) { + setInvoices(res.data); + if (selectId != null) { + const match = res.data.find((i) => i.id === selectId); + if (match) setSelected(match); + } + } setLoading(false); } @@ -161,14 +167,14 @@ export function InvoicingView() { {createOpen && ( setCreateOpen(false)} - onCreated={() => { setCreateOpen(false); load(); }} + onCreated={async (newId) => { await load(newId); setCreateOpen(false); }} /> )} ); } -function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { +function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCreated: (newId?: number) => Promise | void }) { const [projects, setProjects] = useState([]); const [projectId, setProjectId] = useState(null); const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString().slice(0, 10)); @@ -215,10 +221,13 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr if (!qty || qty <= 0) { setError("Enter a valid quantity"); setSubmitting(false); return; } params.manual_quantity = qty; } - const res = await rpc("invoicing.create", params); + const res = await rpc<{ id?: number }>("invoicing.create", params); + if (res.ok) { + await onCreated(res.data?.id); + } else { + setError(res.error || "Failed to create invoice"); + } setSubmitting(false); - if (res.ok) onCreated(); - else setError(res.error || "Failed to create invoice"); } return ( From 8da0669bf36e5d7f1e76b4add55134329a7ca625 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 13 May 2026 13:20:35 +0200 Subject: [PATCH 6/9] fix: invoicing view update on invoice creation --- .../src/components/invoicing/InvoicingView.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tuttle-electron/src/components/invoicing/InvoicingView.tsx b/tuttle-electron/src/components/invoicing/InvoicingView.tsx index 851eea17..40f13d74 100644 --- a/tuttle-electron/src/components/invoicing/InvoicingView.tsx +++ b/tuttle-electron/src/components/invoicing/InvoicingView.tsx @@ -37,6 +37,7 @@ export function InvoicingView() { const [statusFilter, setStatusFilter] = useState("All"); const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); + const [newlyCreatedId, setNewlyCreatedId] = useState(null); const defaultColumn = useCallback( (e: { id: number; [k: string]: unknown }) => invoiceStatus(e as Entity), [], @@ -50,9 +51,10 @@ export function InvoicingView() { const res = await rpc("invoicing.get_all"); if (res.ok && res.data) { setInvoices(res.data); - if (selectId != null) { - const match = res.data.find((i) => i.id === selectId); - if (match) setSelected(match); + const refreshId = selectId ?? selected?.id; + if (refreshId != null) { + const match = res.data.find((i) => i.id === refreshId); + setSelected(match ?? null); } } setLoading(false); @@ -136,8 +138,8 @@ export function InvoicingView() { ?
{search ? "No matches." : "No invoices."}
: filtered.map((inv) => { const isSelected = selected?.id === inv.id; - const isHighlighted = !isSelected && navFilter.contractId != null && num(inv, "contract_id") === navFilter.contractId; - return setSelected(inv)} />; + const isHighlighted = !isSelected && (inv.id === newlyCreatedId || (navFilter.contractId != null && num(inv, "contract_id") === navFilter.contractId)); + return { setNewlyCreatedId(null); setSelected(inv); }} />; })}
@@ -167,7 +169,7 @@ export function InvoicingView() { {createOpen && ( setCreateOpen(false)} - onCreated={async (newId) => { await load(newId); setCreateOpen(false); }} + onCreated={async (newId) => { setNewlyCreatedId(newId ?? null); await load(newId); setCreateOpen(false); }} /> )}
From b43df594c22a09a2e2590b731402aebe35ec9d77 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 13 May 2026 13:42:20 +0200 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20update=20tax=20calculations=20a?= =?UTF-8?q?nd=20views=20for=20monthly=20breakdowns.=20+=20Modify=20`comput?= =?UTF-8?q?e=5Fincome=5Ftax=5Freserve`=20and=20`compute=5Fspendable=5Finco?= =?UTF-8?q?me`=20to=20accept=20optional=20`year`=20parameter=20for=20past?= =?UTF-8?q?=20year=20handling.=20+=20Change=20`quarterly=5Fvat=5Fbreakdown?= =?UTF-8?q?`=20to=20`monthly=5Fvat=5Fbreakdown`=20for=20monthly=20VAT=20ca?= =?UTF-8?q?lculations.=20+=20Update=20`TaxReservesView`=20to=20reflect=20m?= =?UTF-8?q?onthly=20data=20and=20add=20year=20selection=20functionality.?= =?UTF-8?q?=20+=20Enhance=20tests=20to=20validate=20monthly=20VAT=20breakd?= =?UTF-8?q?own=20logic.=20=E2=88=B4=20Improves=20accuracy=20and=20flexibil?= =?UTF-8?q?ity=20in=20tax=20reporting.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/python-env.mdc | 22 ++++ justfile | 7 +- .../src/components/tax/TaxReservesView.tsx | 109 +++++++++++------ tuttle/app/tax/intent.py | 66 +++++++---- tuttle/tax_reserves.py | 111 +++++++++++------- tuttle_tests/test_tax.py | 30 ++--- 6 files changed, 223 insertions(+), 122 deletions(-) create mode 100644 .cursor/rules/python-env.mdc diff --git a/.cursor/rules/python-env.mdc b/.cursor/rules/python-env.mdc new file mode 100644 index 00000000..b59b863a --- /dev/null +++ b/.cursor/rules/python-env.mdc @@ -0,0 +1,22 @@ +--- +description: Python environment is managed by uv with a .venv virtualenv +alwaysApply: true +--- + +# Python Environment + +This project uses **uv** for dependency management. The virtualenv lives at `.venv/`. + +```bash +# ✅ Run Python +.venv/bin/python -m pytest ... +.venv/bin/python -c "..." + +# ✅ Or use just (which uses .venv/bin/python internally) +just test + +# ❌ NEVER use system python or miniforge +python -m pytest ... +``` + +When running any Python command (tests, scripts, imports), always prefix with `.venv/bin/python` or use `just`. diff --git a/justfile b/justfile index 836e6c26..4e4864b9 100644 --- a/justfile +++ b/justfile @@ -11,16 +11,13 @@ app := electron / "release/mac-arm64/Tuttle.app" # ── Development ───────────────────────────────────────────────────────────── # Electron + Vite dev server (hot reload, live Python from .venv) +# vite-plugin-electron auto-launches Electron when Vite starts in dev mode. dev: #!/usr/bin/env bash set -euo pipefail cd "{{electron}}" npm run build - npx vite & - VITE_PID=$! - sleep 2 - VITE_DEV_SERVER_URL=http://localhost:5173 npx electron . - kill $VITE_PID 2>/dev/null || true + VITE_DEV_SERVER_URL=http://localhost:5173 npx vite # ── Build ─────────────────────────────────────────────────────────────────── diff --git a/tuttle-electron/src/components/tax/TaxReservesView.tsx b/tuttle-electron/src/components/tax/TaxReservesView.tsx index fa8f5fbb..dd1242ed 100644 --- a/tuttle-electron/src/components/tax/TaxReservesView.tsx +++ b/tuttle-electron/src/components/tax/TaxReservesView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { BarChart3, Receipt, Calculator } from "lucide-react"; +import { BarChart3, Receipt, Calculator, ChevronDown } from "lucide-react"; import { rpc } from "../../api/rpc"; import type { Entity } from "../../api/types"; import { str, num, bool } from "../../api/entity"; @@ -17,18 +17,34 @@ function fmtPct(value: number): string { export function TaxReservesView() { const [spending, setSpending] = useState(null); const [taxEstimate, setTaxEstimate] = useState(null); - const [quarters, setQuarters] = useState([]); + const [months, setMonths] = useState([]); const [currency, setCurrency] = useState("EUR"); const [loading, setLoading] = useState(true); - - useEffect(() => { load(); }, []); - - async function load() { + const [availableYears, setAvailableYears] = useState([]); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + + useEffect(() => { + rpc("tax.get_available_years").then((res) => { + if (res.ok && Array.isArray(res.data) && res.data.length > 0) { + setAvailableYears(res.data); + if (!res.data.includes(selectedYear)) { + setSelectedYear(res.data[0]); + } + } else { + setAvailableYears([new Date().getFullYear()]); + } + }); + }, []); + + useEffect(() => { load(selectedYear); }, [selectedYear]); + + async function load(year: number) { setLoading(true); + const params = { year }; const [spRes, taxRes, vatRes] = await Promise.all([ - rpc("tax.get_spendable_income"), - rpc("tax.get_income_tax_estimate"), - rpc("tax.get_quarterly_vat"), + rpc("tax.get_spendable_income", params), + rpc("tax.get_income_tax_estimate", params), + rpc("tax.get_monthly_vat", params), ]); if (spRes.ok && spRes.data) { setSpending(spRes.data as Entity); @@ -37,7 +53,7 @@ export function TaxReservesView() { if (taxRes.ok && taxRes.data) setTaxEstimate(taxRes.data as Entity); if (vatRes.ok && vatRes.data) { const d = vatRes.data as Entity; - setQuarters((d.quarters as Entity[]) || []); + setMonths((d.months as Entity[]) || []); if (str(d, "currency")) setCurrency(str(d, "currency")); } setLoading(false); @@ -47,16 +63,27 @@ export function TaxReservesView() { const sp = spending?.spending as Entity | undefined; const hasData = sp && num(sp, "gross_revenue_ytd") > 0; + const isCurrentYear = selectedYear === new Date().getFullYear(); + const periodLabel = isCurrentYear ? "YTD" : `${selectedYear}`; return (
-
-

Tax & Reserves

-

How much of your revenue can you actually spend?

+
+
+

Tax & Reserves

+

How much of your revenue can you actually spend?

+
+ {availableYears.length > 1 && ( + + )}
{/* Revenue Waterfall */} -
}> +
}> {!hasData ? (

No revenue data yet.

) : ( @@ -75,30 +102,29 @@ export function TaxReservesView() { )}
- {/* Quarterly VAT */} -
}> - {quarters.length === 0 ? ( + {/* Monthly VAT */} +
}> + {months.length === 0 ? (

No VAT data available.

) : (
-
- QuarterPeriodInvoicesVAT Collected +
+ MonthInvoicesVAT Collected
- {quarters.map((q, i) => { - const vat = num(q, "vat_collected"); + {months.map((m, i) => { + const vat = num(m, "vat_collected"); return ( -
- {str(q, "quarter")} - {fmtPeriod(str(q, "period_start"), str(q, "period_end"))} - {num(q, "invoice_count")} +
+ {str(m, "month")} + {num(m, "invoice_count")} 0 ? "text-yellow-400" : "text-muted"}`}>{fmt(vat, currency)}
); })}
Total - s + num(q, "vat_collected"), 0) > 0 ? "text-yellow-400" : "text-muted"}> - {fmt(quarters.reduce((s, q) => s + num(q, "vat_collected"), 0), currency)} + s + num(m, "vat_collected"), 0) > 0 ? "text-yellow-400" : "text-muted"}> + {fmt(months.reduce((s, m) => s + num(m, "vat_collected"), 0), currency)}
@@ -111,6 +137,25 @@ export function TaxReservesView() { ); } +function YearSelector({ years, selected, onChange }: { + years: number[]; selected: number; onChange: (y: number) => void; +}) { + return ( +
+ + +
+ ); +} + function IncomeTaxSection({ data, currency }: { data: Entity; currency: string }) { const tr = data.tax_reserve as Entity | undefined; const brackets = (data.brackets as Entity[]) || []; @@ -131,7 +176,7 @@ function IncomeTaxSection({ data, currency }: { data: Entity; currency: string } )} {brackets.length > 0 && (
-
Tax Brackets
+
Tax Brackets (marginal rates)
{brackets.map((b, i) => { const current = bool(b, "is_current"); @@ -201,11 +246,3 @@ function SummaryRow({ label, value, color, bold }: { label: string; value: strin
); } - -function fmtPeriod(start: string, end: string): string { - try { - const s = new Date(start).toLocaleDateString("en-US", { month: "short" }); - const e = new Date(end).toLocaleDateString("en-US", { month: "short" }); - return `${s} – ${e}`; - } catch { return ""; } -} diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py index 5c1e9214..b151adf3 100644 --- a/tuttle/app/tax/intent.py +++ b/tuttle/app/tax/intent.py @@ -11,7 +11,7 @@ from ...tax_reserves import ( compute_spendable_income, compute_income_tax_reserve, - quarterly_vat_breakdown, + monthly_vat_breakdown, ) @@ -38,13 +38,15 @@ def _get_tax_currency(self, country: str) -> str: except NotImplementedError: return "EUR" - def get_spendable_income(self) -> IntentResult: + def get_spendable_income(self, year: int | None = None) -> IntentResult: """Compute spendable income breakdown.""" try: invoices = self.query(Invoice) country = self._get_country() currency = self._get_tax_currency(country) - spending = compute_spendable_income(invoices, country, currency=currency) + spending = compute_spendable_income( + invoices, country, currency=currency, year=year + ) data = {"spending": spending, "currency": currency} return IntentResult(was_intent_successful=True, data=data) except Exception as e: @@ -55,26 +57,31 @@ def get_spendable_income(self) -> IntentResult: exception=e, ) - def get_income_tax_estimate(self) -> IntentResult: + def get_income_tax_estimate(self, year: int | None = None) -> IntentResult: """Get detailed income tax estimate with bracket info.""" try: + today = datetime.date.today() + is_past_year = year is not None and year < today.year + invoices = self.query(Invoice) country = self._get_country() currency = self._get_tax_currency(country) - spending = compute_spendable_income(invoices, country, currency=currency) - tax_reserve = compute_income_tax_reserve(spending.net_revenue_ytd, country) - - days_elapsed = max( - ( - datetime.date.today() - - datetime.date.today().replace(month=1, day=1) - ).days, - 1, + spending = compute_spendable_income( + invoices, country, currency=currency, year=year + ) + tax_reserve = compute_income_tax_reserve( + spending.net_revenue_ytd, country, year=year ) - annualized = float(spending.net_revenue_ytd) * 365 / days_elapsed + if is_past_year: + annualized = float(spending.net_revenue_ytd) + else: + days_elapsed = max((today - today.replace(month=1, day=1)).days, 1) + annualized = float(spending.net_revenue_ytd) * 365 / days_elapsed + + ref_date = datetime.date(year, 7, 1) if year else today try: - tax_system = get_tax_system(country) + tax_system = get_tax_system(country, date=ref_date) bracket_data = self._compute_bracket_data( tax_system, Decimal(str(annualized)) ) @@ -134,23 +141,40 @@ def _compute_bracket_data(self, tax_system, annualized_income: Decimal) -> list: prev_end = end return brackets + def get_available_years(self) -> IntentResult: + """Return distinct years (descending) that have invoice data.""" + try: + invoices = self.query(Invoice) + years = sorted( + {inv.date.year for inv in invoices if not inv.cancelled}, + reverse=True, + ) + return IntentResult(was_intent_successful=True, data=years) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to get available years.", + log_message=f"TaxIntent.get_available_years: {e}", + exception=e, + ) + def supported_countries(self) -> IntentResult: """Return list of countries with tax system support.""" return IntentResult(was_intent_successful=True, data=supported_countries()) - def get_quarterly_vat(self, year: int | None = None) -> IntentResult: - """Get quarterly VAT breakdown.""" + def get_monthly_vat(self, year: int | None = None) -> IntentResult: + """Get monthly VAT breakdown.""" try: invoices = self.query(Invoice) country = self._get_country() currency = self._get_tax_currency(country) - quarters = quarterly_vat_breakdown(invoices, year=year, currency=currency) - data = {"quarters": quarters, "currency": currency} + months = monthly_vat_breakdown(invoices, year=year, currency=currency) + data = {"months": months, "currency": currency} return IntentResult(was_intent_successful=True, data=data) except Exception as e: return IntentResult( was_intent_successful=False, - error_msg="Failed to compute quarterly VAT.", - log_message=f"TaxIntent.get_quarterly_vat: {e}", + error_msg="Failed to compute monthly VAT.", + log_message=f"TaxIntent.get_monthly_vat: {e}", exception=e, ) diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py index c446778e..919a9665 100644 --- a/tuttle/tax_reserves.py +++ b/tuttle/tax_reserves.py @@ -4,6 +4,7 @@ VAT payments and estimated income tax, yielding the actual spendable income. """ +import calendar import datetime import logging from decimal import Decimal @@ -93,48 +94,58 @@ def compute_income_tax_reserve( net_revenue_ytd: Decimal, country: str, deductions: Decimal = Decimal(0), + year: Optional[int] = None, ) -> IncomeTaxReserve: """Estimate income tax reserve based on year-to-date net revenue. - Annualizes the YTD net revenue, computes the tax on that projected - annual income, then prorates back to the current date. + For the current year: annualizes YTD net revenue, computes the tax on + that projected annual income, then prorates back to the current date. + + For a past year (*year* given and < current year): treats *net_revenue_ytd* + as the full-year amount -- no annualization or proration. """ today = datetime.date.today() + _zero = IncomeTaxReserve( + estimated_annual_tax=Decimal(0), + solidarity_surcharge=Decimal(0), + total_annual_reserve=Decimal(0), + ytd_reserve=Decimal(0), + effective_rate=Decimal(0), + ) + + ref_date = datetime.date(year, 7, 1) if year is not None else today + is_past_year = year is not None and year < today.year + try: - tax_system = get_tax_system(country, date=today) + tax_system = get_tax_system(country, date=ref_date) except NotImplementedError: - return IncomeTaxReserve( - estimated_annual_tax=Decimal(0), - solidarity_surcharge=Decimal(0), - total_annual_reserve=Decimal(0), - ytd_reserve=Decimal(0), - effective_rate=Decimal(0), - ) - year_start = today.replace(month=1, day=1) - days_elapsed = max((today - year_start).days, 1) - days_in_year = 365 + return _zero - # Annualize: project YTD revenue to full year - annualized_income = (net_revenue_ytd - deductions) * days_in_year / days_elapsed + if is_past_year: + annualized_income = net_revenue_ytd - deductions + else: + year_start = today.replace(month=1, day=1) + days_elapsed = max((today - year_start).days, 1) + days_in_year = 365 + annualized_income = (net_revenue_ytd - deductions) * days_in_year / days_elapsed if annualized_income <= 0: - return IncomeTaxReserve( - estimated_annual_tax=Decimal(0), - solidarity_surcharge=Decimal(0), - total_annual_reserve=Decimal(0), - ytd_reserve=Decimal(0), - effective_rate=Decimal(0), - ) + return _zero - # Compute annual tax annual_tax = tax_system.income_tax(annualized_income) annual_soli = tax_system.solidarity_surcharge(annual_tax) total_annual = annual_tax + annual_soli - # Prorate to current date - ytd_reserve = (total_annual * days_elapsed / days_in_year).quantize(Decimal("0.01")) + if is_past_year: + ytd_reserve = total_annual.quantize(Decimal("0.01")) + else: + year_start = today.replace(month=1, day=1) + days_elapsed = max((today - year_start).days, 1) + days_in_year = 365 + ytd_reserve = (total_annual * days_elapsed / days_in_year).quantize( + Decimal("0.01") + ) - # Effective rate effective_rate = ( (total_annual / annualized_income) if annualized_income > 0 else Decimal(0) ) @@ -153,21 +164,34 @@ def compute_spendable_income( country: str, deductions: Decimal = Decimal(0), currency: Optional[str] = None, + year: Optional[int] = None, ) -> SpendableIncome: """Compute spendable income: what's left after VAT and income tax reserves. This answers the freelancer's core question: "How much of this money is mine?" + If *year* is given (and is a past year), the full calendar year is used + without annualization. Otherwise the current YTD is used. + If *currency* is given (the tax system's native currency), only invoices denominated in that currency are counted. If not given, the currency is resolved automatically from the tax system for *country*. """ today = datetime.date.today() - year_start = today.replace(month=1, day=1) + is_past_year = year is not None and year < today.year + + if year is not None and is_past_year: + year_start = datetime.date(year, 1, 1) + year_end = datetime.date(year, 12, 31) + else: + year_start = today.replace(month=1, day=1) + year_end = today + + ref_date = datetime.date(year, 7, 1) if year is not None else today if currency is None: try: - tax_system = get_tax_system(country, date=today) + tax_system = get_tax_system(country, date=ref_date) currency = tax_system.currency except NotImplementedError: pass @@ -179,7 +203,7 @@ def compute_spendable_income( for inv in invoices: if inv.cancelled: continue - if inv.date >= year_start: + if year_start <= inv.date <= year_end: if currency and _invoice_currency(inv) not in (currency, None): skipped += 1 continue @@ -195,7 +219,7 @@ def compute_spendable_income( net_ytd = gross_ytd - vat_ytd - tax_reserve = compute_income_tax_reserve(net_ytd, country, deductions) + tax_reserve = compute_income_tax_reserve(net_ytd, country, deductions, year=year) spendable = net_ytd - tax_reserve.ytd_reserve @@ -339,40 +363,37 @@ def compute_effective_salary( ) -def quarterly_vat_breakdown( +def monthly_vat_breakdown( invoices: List[Invoice], year: Optional[int] = None, currency: Optional[str] = None, ) -> list: - """VAT breakdown by quarter for the given year. + """VAT breakdown by month for the given year. - Returns list of dicts: quarter, vat_collected, invoice_count, period_start, period_end. + Returns list of dicts: month, vat_collected, invoice_count, period_start, period_end. """ if year is None: year = datetime.date.today().year - quarters = [] - for q in range(1, 5): - start_month = (q - 1) * 3 + 1 - end_month = q * 3 - period_start = datetime.date(year, start_month, 1) - if end_month == 12: + month_names = [calendar.month_abbr[m] for m in range(1, 13)] + months = [] + for m in range(1, 13): + period_start = datetime.date(year, m, 1) + if m == 12: period_end = datetime.date(year, 12, 31) else: - period_end = datetime.date(year, end_month + 1, 1) - datetime.timedelta( - days=1 - ) + period_end = datetime.date(year, m + 1, 1) - datetime.timedelta(days=1) reserve = compute_vat_reserves( invoices, period_start, period_end, currency=currency ) - quarters.append( + months.append( { - "quarter": f"Q{q}", + "month": month_names[m - 1], "vat_collected": reserve.vat_collected, "invoice_count": reserve.invoice_count, "period_start": period_start, "period_end": period_end, } ) - return quarters + return months diff --git a/tuttle_tests/test_tax.py b/tuttle_tests/test_tax.py index 36db6cdf..563064d5 100644 --- a/tuttle_tests/test_tax.py +++ b/tuttle_tests/test_tax.py @@ -11,7 +11,7 @@ compute_income_tax_reserve, compute_spendable_income, compute_vat_reserves, - quarterly_vat_breakdown, + monthly_vat_breakdown, ) from tuttle.kpi import monthly_spendable_breakdown from tuttle.model import ( @@ -372,30 +372,30 @@ def test_monthly_spendable_breakdown_includes_vat_subtraction(self): ) -# ── Quarterly VAT breakdown ────────────────────────────────── +# ── Monthly VAT breakdown ───────────────────────────────────── -class TestQuarterlyVAT: - def test_four_quarters(self): +class TestMonthlyVAT: + def test_twelve_months(self): invoices = [ _make_invoice(datetime.date(2026, 1, 15), [(10, 100, 0.19)]), _make_invoice(datetime.date(2026, 5, 15), [(20, 100, 0.19)]), _make_invoice(datetime.date(2026, 9, 15), [(30, 100, 0.19)]), ] - result = quarterly_vat_breakdown(invoices, year=2026) - assert len(result) == 4 - assert result[0]["quarter"] == "Q1" + result = monthly_vat_breakdown(invoices, year=2026) + assert len(result) == 12 + assert result[0]["month"] == "Jan" assert result[0]["invoice_count"] == 1 - assert result[1]["quarter"] == "Q2" - assert result[1]["invoice_count"] == 1 - assert result[2]["quarter"] == "Q3" - assert result[2]["invoice_count"] == 1 - assert result[3]["quarter"] == "Q4" - assert result[3]["invoice_count"] == 0 + assert result[4]["month"] == "May" + assert result[4]["invoice_count"] == 1 + assert result[8]["month"] == "Sep" + assert result[8]["invoice_count"] == 1 + assert result[11]["month"] == "Dec" + assert result[11]["invoice_count"] == 0 def test_defaults_to_current_year(self): - result = quarterly_vat_breakdown([], year=None) - assert len(result) == 4 + result = monthly_vat_breakdown([], year=None) + assert len(result) == 12 assert result[0]["period_start"].year == datetime.date.today().year From d8f0a07d53e215d2571b4daefd32b43b28441fe2 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 13 May 2026 16:59:52 +0200 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20add=20today=20position=20tracking?= =?UTF-8?q?=20in=20timeline=20view.=20+=20Implement=20`todayPosition`=20us?= =?UTF-8?q?ing=20`useMemo`=20to=20determine=20today's=20event=20location.?= =?UTF-8?q?=20+=20Update=20event=20rendering=20logic=20to=20display=20`Tod?= =?UTF-8?q?ayMarker`=20based=20on=20`todayPosition`.=20=E2=88=B4=20Enhance?= =?UTF-8?q?s=20timeline=20view=20with=20improved=20event=20visibility=20fo?= =?UTF-8?q?r=20the=20current=20date.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/timeline/TimelineView.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tuttle-electron/src/components/timeline/TimelineView.tsx b/tuttle-electron/src/components/timeline/TimelineView.tsx index 7bce514d..15d459c8 100644 --- a/tuttle-electron/src/components/timeline/TimelineView.tsx +++ b/tuttle-electron/src/components/timeline/TimelineView.tsx @@ -102,6 +102,18 @@ export function TimelineView() { const todayISO = new Date().toISOString().slice(0, 10); + const todayPosition = useMemo<{ group: number; event: number } | null>(() => { + for (let gi = 0; gi < groups.length; gi++) { + for (let ei = 0; ei < groups[gi].events.length; ei++) { + const ev = groups[gi].events[ei]; + if (!bool(ev, "is_future") && str(ev, "date") <= todayISO) { + return { group: gi, event: ei }; + } + } + } + return null; + }, [groups, todayISO]); + if (loading) return
Loading timeline…
; if (!events.length) { @@ -157,9 +169,7 @@ export function TimelineView() { {/* Timeline */}
- {groups.map((group, gi) => { - let todayInserted = false; - return ( + {groups.map((group, gi) => (
{/* Month header */}
0 ? 8 : 0 }}> @@ -173,23 +183,19 @@ export function TimelineView() { {group.events.map((ev, ei) => { const isLast = gi === groups.length - 1 && ei === group.events.length - 1; - const evDate = str(ev, "date"); - const isFuture = bool(ev, "is_future"); - const showToday = !todayInserted && !isFuture && evDate <= todayISO; - if (showToday) todayInserted = true; + const showToday = todayPosition?.group === gi && todayPosition?.event === ei; return ( -
+
{showToday && }
); })} - {gi === groups.length - 1 && !todayInserted && } + {gi === groups.length - 1 && !todayPosition && }
- ); - })} + ))}
); From 7e9cbdcfccbc75d76d08d580d8acd184a2fadc9f Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 13 May 2026 20:20:52 +0200 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20integrate=20project=20time=20budget?= =?UTF-8?q?s=20into=20views.=20+=20Add=20`ProgressBar`=20component=20for?= =?UTF-8?q?=20visualizing=20budget=20progress.=20+=20Update=20`DashboardVi?= =?UTF-8?q?ew`=20to=20fetch=20and=20display=20project=20budgets.=20+=20Mod?= =?UTF-8?q?ify=20`ProjectsView`=20to=20include=20budget=20information=20in?= =?UTF-8?q?=20project=20details.=20+=20Enhance=20`project=5Fbudget=5Fstatu?= =?UTF-8?q?s`=20to=20return=20project=20ID=20and=20skip=20projects=20witho?= =?UTF-8?q?ut=20tracked=20time.=20=E2=88=B4=20Improves=20project=20managem?= =?UTF-8?q?ent=20visibility=20and=20budget=20tracking.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/business/ProjectsView.tsx | 36 +++++++++++++++++-- .../components/dashboard/DashboardView.tsx | 28 ++++++++++++++- .../src/components/shared/ProgressBar.tsx | 26 ++++++++++++++ tuttle/kpi.py | 10 +++++- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tuttle-electron/src/components/shared/ProgressBar.tsx diff --git a/tuttle-electron/src/components/business/ProjectsView.tsx b/tuttle-electron/src/components/business/ProjectsView.tsx index 7ccd87da..ca0819d9 100644 --- a/tuttle-electron/src/components/business/ProjectsView.tsx +++ b/tuttle-electron/src/components/business/ProjectsView.tsx @@ -6,11 +6,20 @@ import { import { rpc } from "../../api/rpc"; import { str, int, num, bool, entity, dateRange, projectStatus } from "../../api/entity"; import { StatusBadge } from "../shared/StatusBadge"; +import { ProgressBar } from "../shared/ProgressBar"; import { ViewModeToggle } from "../shared/ViewModeToggle"; import { KanbanBoard, useStageStore, type BoardColumn } from "../shared/KanbanBoard"; import { useNavigation } from "../shared/NavigationContext"; import type { Entity } from "../../api/types"; +interface BudgetEntry { + project_id: number; + project: string; + hours_tracked: number; + hours_budget: number; + progress: number; +} + type Mode = "view" | "edit" | "create" | "import"; const PROJECT_COLUMNS: BoardColumn[] = [ @@ -31,6 +40,7 @@ export function ProjectsView() { const { filter: navFilter } = useNavigation(); const [projects, setProjects] = useState([]); const [contractsMap, setContractsMap] = useState>({}); + const [budgetsMap, setBudgetsMap] = useState>({}); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState<"list" | "board">("list"); @@ -56,9 +66,10 @@ export function ProjectsView() { async function load() { setLoading(true); - const [res, cRes] = await Promise.all([ + const [res, cRes, bRes] = await Promise.all([ rpc("projects.get_all"), rpc>("projects.get_all_contracts"), + rpc("dashboard.get_project_budgets"), ]); if (res.ok && res.data) { setProjects(res.data); @@ -69,6 +80,11 @@ export function ProjectsView() { } } if (cRes.ok && cRes.data) setContractsMap(cRes.data); + if (bRes.ok && Array.isArray(bRes.data)) { + const map: Record = {}; + for (const b of bRes.data) map[b.project_id] = b; + setBudgetsMap(map); + } setLoading(false); } @@ -285,6 +301,13 @@ export function ProjectsView() {
+ {selected.id != null && budgetsMap[selected.id as number] && ( + + )}
) : (
@@ -297,7 +320,7 @@ export function ProjectsView() {
stageStore.columnFor(e)} onMove={moveToColumn} - renderCard={(proj, col) => } /> + renderCard={(proj, col) => } />
)}
@@ -311,9 +334,10 @@ function clientName(p: Entity): string { return c ? str(entity(c, "client") || ({} as Entity), "name") : ""; } -function ProjectCard({ project }: { project: Entity; color: string }) { +function ProjectCard({ project, budgetsMap }: { project: Entity; color: string; budgetsMap: Record }) { const cName = clientName(project); const c = entity(project, "contract"); + const budget = project.id != null ? budgetsMap[project.id as number] : undefined; return (
@@ -340,6 +364,12 @@ function ProjectCard({ project }: { project: Entity; color: string }) { {dateRange(project)}
)} + {budget && ( + + )}
); } diff --git a/tuttle-electron/src/components/dashboard/DashboardView.tsx b/tuttle-electron/src/components/dashboard/DashboardView.tsx index 60e3b15b..24f1605a 100644 --- a/tuttle-electron/src/components/dashboard/DashboardView.tsx +++ b/tuttle-electron/src/components/dashboard/DashboardView.tsx @@ -6,23 +6,35 @@ import { import { rpc } from "../../api/rpc"; import { str, num, int } from "../../api/entity"; import { KPICard } from "../shared/KPICard"; +import { ProgressBar } from "../shared/ProgressBar"; import type { Entity } from "../../api/types"; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts"; +interface BudgetEntry { + project_id: number; + project: string; + hours_tracked: number; + hours_budget: number; + progress: number; +} + export function DashboardView() { const [kpis, setKpis] = useState(null); const [chartData, setChartData] = useState<{ label: string; revenue: number; spendable: number }[]>([]); + const [budgets, setBudgets] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { load(); }, []); async function load() { setLoading(true); - const [kpiRes, chartRes] = await Promise.all([ + const [kpiRes, chartRes, budgetRes] = await Promise.all([ rpc("dashboard.get_kpis"), rpc("dashboard.get_monthly_chart_data", { n_months: 12 }), + rpc("dashboard.get_project_budgets"), ]); if (kpiRes.ok && kpiRes.data) setKpis(kpiRes.data as Entity); + if (budgetRes.ok && Array.isArray(budgetRes.data)) setBudgets(budgetRes.data); if (chartRes.ok && chartRes.data) { const d = chartRes.data as { revenue: Entity[]; spendable: Entity[] }; const rev = (d.revenue || []) as Entity[]; @@ -84,6 +96,20 @@ export function DashboardView() {
)} + + {budgets.length > 0 && ( +
+

Project Time Budgets

+ {budgets.map((b) => ( + + ))} +
+ )}
); } diff --git a/tuttle-electron/src/components/shared/ProgressBar.tsx b/tuttle-electron/src/components/shared/ProgressBar.tsx new file mode 100644 index 00000000..2bf17607 --- /dev/null +++ b/tuttle-electron/src/components/shared/ProgressBar.tsx @@ -0,0 +1,26 @@ +interface ProgressBarProps { + progress: number; + label?: string; + subtitle?: string; +} + +export function ProgressBar({ progress, label, subtitle }: ProgressBarProps) { + const clamped = Math.max(0, Math.min(progress, 1)); + + return ( +
+ {(label || subtitle) && ( +
+ {label && {label}} + {subtitle && {subtitle}} +
+ )} +
+
+
+
+ ); +} diff --git a/tuttle/kpi.py b/tuttle/kpi.py index 11d8322e..b28b4b77 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -5,6 +5,7 @@ from typing import List, Optional, NamedTuple from .model import Contract, Invoice, Project, User +from .time import TimeUnit from .tax import get_tax_system from .tax_reserves import compute_spendable_income @@ -287,7 +288,9 @@ def project_budget_status( ) -> list: """Budget utilization for each project with timesheets. - Returns a list of dicts with keys: project, hours_tracked, hours_budget, progress. + Returns a list of dicts with keys: project_id, project, hours_tracked, + hours_budget, progress. Skips projects without a contract volume or + without any tracked time. """ results = [] for project in projects: @@ -297,11 +300,16 @@ def project_budget_status( for ts in project.timesheets: for item in ts.items: hours_tracked += Decimal(str(item.duration.total_seconds() / 3600)) + if hours_tracked == 0: + continue hours_budget = Decimal(str(project.contract.volume)) + if project.contract.unit == TimeUnit.day: + hours_budget *= project.contract.units_per_workday progress = float(hours_tracked / hours_budget) if hours_budget > 0 else 0.0 results.append( { + "project_id": project.id, "project": project.title, "hours_tracked": float(hours_tracked), "hours_budget": float(hours_budget),