Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .cursor/rules/python-env.mdc
Original file line number Diff line number Diff line change
@@ -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`.
24 changes: 24 additions & 0 deletions .cursor/rules/python-imports.mdc
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 41 additions & 8 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand All @@ -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 ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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')"
8 changes: 6 additions & 2 deletions templates/invoice-anvil/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@
<tr>
<td rowspan="2" class="client-name">
{{ invoice.contract.client.name }}<br>
c/o {{ invoice.contract.client.invoicing_contact.name}}<br>
{{ invoice.contract.client.invoicing_contact.address.html }}
{% if invoice.contract.client.invoicing_contact %}
c/o {{ invoice.contract.client.invoicing_contact.name }}<br>
{% endif %}
{% if invoice.contract.client.invoice_recipient_address %}
{{ invoice.contract.client.invoice_recipient_address.html }}
{% endif %}
</td>
<td>
<big>{{ user.name }}</big><br>
Expand Down
6 changes: 5 additions & 1 deletion templates/invoice-classic/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@
<span class="party-label">Bill To</span>
<div class="party-value">
<strong>{{ invoice.contract.client.name }}</strong><br>
{% if invoice.contract.client.invoicing_contact %}
c/o {{ invoice.contract.client.invoicing_contact.name }}<br>
{{ invoice.contract.client.invoicing_contact.address.html }}
{% endif %}
{% if invoice.contract.client.invoice_recipient_address %}
{{ invoice.contract.client.invoice_recipient_address.html }}
{% endif %}
</div>
</div>
</div>
Expand Down
10 changes: 7 additions & 3 deletions templates/invoice-minimal/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
<div class="return-line">{{ user.name }} · {{ user.address.street }} {{ user.address.number }} · {{ user.address.postal_code }} {{ user.address.city }}</div>
<div class="recipient">
<div><strong>{{ invoice.contract.client.name }}</strong></div>
{% if invoice.contract.client.invoicing_contact %}
<div>c/o {{ invoice.contract.client.invoicing_contact.name }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.street }} {{ invoice.contract.client.invoicing_contact.address.number }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.postal_code }} {{ invoice.contract.client.invoicing_contact.address.city }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.country }}</div>
{% endif %}
{% if invoice.contract.client.invoice_recipient_address %}
<div>{{ invoice.contract.client.invoice_recipient_address.street }} {{ invoice.contract.client.invoice_recipient_address.number }}</div>
<div>{{ invoice.contract.client.invoice_recipient_address.postal_code }} {{ invoice.contract.client.invoice_recipient_address.city }}</div>
<div>{{ invoice.contract.client.invoice_recipient_address.country }}</div>
{% endif %}
</div>
</div>
<div class="letterhead">
Expand Down
10 changes: 7 additions & 3 deletions templates/invoice-modern/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
<div class="return-line">{{ user.name }} · {{ user.address.street }} {{ user.address.number }} · {{ user.address.postal_code }} {{ user.address.city }}</div>
<div class="recipient">
<div><strong>{{ invoice.contract.client.name }}</strong></div>
{% if invoice.contract.client.invoicing_contact %}
<div>c/o {{ invoice.contract.client.invoicing_contact.name }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.street }} {{ invoice.contract.client.invoicing_contact.address.number }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.postal_code }} {{ invoice.contract.client.invoicing_contact.address.city }}</div>
<div>{{ invoice.contract.client.invoicing_contact.address.country }}</div>
{% endif %}
{% if invoice.contract.client.invoice_recipient_address %}
<div>{{ invoice.contract.client.invoice_recipient_address.street }} {{ invoice.contract.client.invoice_recipient_address.number }}</div>
<div>{{ invoice.contract.client.invoice_recipient_address.postal_code }} {{ invoice.contract.client.invoice_recipient_address.city }}</div>
<div>{{ invoice.contract.client.invoice_recipient_address.country }}</div>
{% endif %}
</div>
</div>
<div class="letterhead">
Expand Down
9 changes: 7 additions & 2 deletions templates/invoice/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ <h1>Invoice No. {{ invoice.number }}</h1>
<div>
<p>
<address id="to">
{{ invoice.contract.client.invoicing_contact.name}}<br>
{{ invoice.contract.client.invoicing_contact.address.html }}
{{ invoice.contract.client.name }}<br>
{% if invoice.contract.client.invoicing_contact %}
c/o {{ invoice.contract.client.invoicing_contact.name }}<br>
{% endif %}
{% if invoice.contract.client.invoice_recipient_address %}
{{ invoice.contract.client.invoice_recipient_address.html }}
{% endif %}
</address>
</p>
</div>
Expand Down
9 changes: 7 additions & 2 deletions templates/timesheet-anvil/timesheet.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@
<table class="invoice-info-container">
<tr>
<td rowspan="2" class="client-name">
{{ timesheet.project.client.invoicing_contact.name}}<br>
{{ timesheet.project.client.invoicing_contact.address.html }}
{{ timesheet.project.client.name }}<br>
{% if timesheet.project.client.invoicing_contact %}
c/o {{ timesheet.project.client.invoicing_contact.name }}<br>
{% endif %}
{% if timesheet.project.client.invoice_recipient_address %}
{{ timesheet.project.client.invoice_recipient_address.html }}
{% endif %}
</td>
<td>
{{ user.name }}<br>
Expand Down
2 changes: 2 additions & 0 deletions tuttle-electron/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("tuttle", {
rpc: (method: string, params: Record<string, unknown> = {}) =>
ipcRenderer.invoke("rpc", method, params),
readFile: (filePath: string) => ipcRenderer.invoke("read-file", filePath),
platform: process.platform,
});
17 changes: 17 additions & 0 deletions tuttle-electron/src/api/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[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[];
Expand Down
84 changes: 66 additions & 18 deletions tuttle-electron/src/components/business/ClientsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -250,21 +261,35 @@ function ClientDetail({ client, onEdit, onDelete, deleteError }: {
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">{deleteError}</div>
)}

<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wider text-secondary mb-2">Invoicing Contact</div>
{contactName && <InfoRow icon={<Users size={14} />} label="Name" value={contactName} />}
{email && <InfoRow icon={<Mail size={14} />} label="Email" value={email} />}
{company && <InfoRow icon={<Building2 size={14} />} label="Company" value={company} />}
{addrParts.length > 0 && (
{clientAddrParts.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wider text-secondary mb-2">Address</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-bg-card border border-border-subtle">
<span className="text-tertiary mt-0.5"><MapPin size={14} /></span>
<div>
<div className="text-xs font-semibold uppercase tracking-wider text-tertiary mb-1">Address</div>
{addrParts.map((line, i) => <div key={i} className="text-sm">{line}</div>)}
{clientAddrParts.map((line, i) => <div key={i} className="text-sm">{line}</div>)}
</div>
</div>
)}
</div>
</div>
)}

{(contactName || email || company || contactAddrParts.length > 0) && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wider text-secondary mb-2">Invoicing Contact</div>
{contactName && <InfoRow icon={<Users size={14} />} label="Name" value={contactName} />}
{email && <InfoRow icon={<Mail size={14} />} label="Email" value={email} />}
{company && <InfoRow icon={<Building2 size={14} />} label="Company" value={company} />}
{contactAddrParts.length > 0 && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-bg-card border border-border-subtle">
<span className="text-tertiary mt-0.5"><MapPin size={14} /></span>
<div>
<div className="text-xs font-semibold uppercase tracking-wider text-tertiary mb-1">Address</div>
{contactAddrParts.map((line, i) => <div key={i} className="text-sm">{line}</div>)}
</div>
</div>
)}
</div>
)}
</div>
);
}
Expand All @@ -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 }: {
Expand All @@ -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<number | null>(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;

Expand All @@ -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);
}

Expand All @@ -329,11 +365,23 @@ function ClientForm({ client, contacts, onSave, onCancel }: {
<FormField label="Name" value={name} onChange={setName} autoFocus required />
</Section>

<Section title="Invoicing Contact">
<Section title="Address">
<div className="grid grid-cols-2 gap-3">
<FormField label="Street" value={street} onChange={setStreet} />
<FormField label="Number" value={number} onChange={setNumber} />
<FormField label="Postal Code" value={postalCode} onChange={setPostalCode} />
<FormField label="City" value={city} onChange={setCity} />
</div>
<div className="mt-3">
<FormField label="Country" value={country} onChange={setCountry} />
</div>
</Section>

<Section title="Invoicing Contact (Optional)">
<label className="block text-xs text-tertiary mb-1">Select Contact</label>
<select value={contactId ?? ""} onChange={(e) => setContactId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 rounded-md text-sm bg-bg-card text-primary border border-border-subtle outline-none focus:border-accent transition-colors">
<option value="">— Select a contact —</option>
<option value="">— No contact —</option>
{contactList.map((c) => (
<option key={c.id} value={c.id}>{displayName(c)}</option>
))}
Expand Down
Loading