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/.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..4e4864b9 100644
--- a/justfile
+++ b/justfile
@@ -10,9 +10,14 @@ 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)
+# vite-plugin-electron auto-launches Electron when Vite starts in dev mode.
dev:
- cd {{electron}} && npx vite
+ #!/usr/bin/env bash
+ set -euo pipefail
+ cd "{{electron}}"
+ npm run build
+ VITE_DEV_SERVER_URL=http://localhost:5173 npx vite
# ── Build ───────────────────────────────────────────────────────────────────
@@ -29,15 +34,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 ─────────────────────────────────────────────────────────────────────
@@ -72,4 +105,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/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/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/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/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 }: {
-
+
+
+
| |