feat: add personal-finance and ai-chat widgets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tanmay Karande
2026-03-17 01:39:16 -04:00
parent 492e0ec47c
commit 1ab88b3758
11 changed files with 532 additions and 0 deletions

View File

@@ -452,6 +452,7 @@ func (a *application) server() (func() error, func() error) {
mux.HandleFunc("GET /api/finance", handleFinanceGet)
mux.HandleFunc("POST /api/finance", handleFinancePost)
mux.HandleFunc("POST /api/ai-chat", handleAIChatProxy)
if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)

View File

@@ -0,0 +1,117 @@
.ai-chat {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ai-chat-messages {
display: flex;
flex-direction: column;
gap: 0.6rem;
max-height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
padding-right: 2px;
}
.ai-chat-messages:empty::before {
content: 'Start a conversation';
color: var(--color-text-subdue);
font-size: 0.9rem;
text-align: center;
padding: 1.5rem 0;
display: block;
opacity: 0.5;
}
.ai-chat-msg {
padding: 0.55rem 0.85rem;
border-radius: 10px;
font-size: 0.95rem;
line-height: 1.55;
max-width: 88%;
word-break: break-word;
white-space: pre-wrap;
}
.ai-chat-msg-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-background);
border-bottom-right-radius: 3px;
}
.ai-chat-msg-assistant {
align-self: flex-start;
background: rgba(255,255,255,0.06);
color: var(--color-text-highlight);
border-bottom-left-radius: 3px;
}
.ai-chat-msg-loading::after {
content: '▋';
animation: ai-chat-blink 0.8s step-end infinite;
opacity: 0.7;
}
@keyframes ai-chat-blink {
0%, 100% { opacity: 0.7; }
50% { opacity: 0; }
}
.ai-chat-input-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.ai-chat-input {
flex: 1;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
color: var(--color-text-highlight);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.5;
padding: 0.55rem 0.75rem;
resize: none;
outline: none;
transition: border-color 0.2s;
min-height: 36px;
max-height: 120px;
}
.ai-chat-input:focus {
border-color: var(--color-primary);
}
.ai-chat-input::placeholder {
color: var(--color-text-subdue);
opacity: 0.6;
}
.ai-chat-send {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: var(--color-primary);
color: var(--color-background);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.ai-chat-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-chat-send:not(:disabled):hover {
opacity: 0.85;
}

View File

@@ -15,6 +15,7 @@
@import "widget-videos.css";
@import "widget-weather.css";
@import "widget-todo.css";
@import "widget-ai-chat.css";
@import "forum-posts.css";

View File

@@ -0,0 +1,97 @@
export default function setupAIChat(el) {
const model = el.dataset.model || 'llama3.2';
const ollamaURL = el.dataset.ollamaUrl || 'http://localhost:11434';
const messagesEl = el.querySelector('.ai-chat-messages');
const inputEl = el.querySelector('.ai-chat-input');
const sendBtn = el.querySelector('.ai-chat-send');
const history = [];
let busy = false;
function appendMessage(role, text) {
const div = document.createElement('div');
div.className = `ai-chat-msg ai-chat-msg-${role}`;
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
async function send() {
const text = inputEl.value.trim();
if (!text || busy) return;
busy = true;
sendBtn.disabled = true;
inputEl.value = '';
inputEl.style.height = 'auto';
history.push({ role: 'user', content: text });
appendMessage('user', text);
const assistantEl = appendMessage('assistant', '');
assistantEl.classList.add('ai-chat-msg-loading');
let reply = '';
try {
const res = await fetch('/api/ai-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ollama_url: ollamaURL, model, messages: history }),
});
if (!res.ok) {
assistantEl.textContent = `Error: ${res.statusText}`;
assistantEl.classList.remove('ai-chat-msg-loading');
history.pop();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value, { stream: true }).split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line);
if (chunk.message && chunk.message.content) {
reply += chunk.message.content;
assistantEl.textContent = reply;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
} catch (_) {}
}
}
} catch (e) {
assistantEl.textContent = 'Failed to reach Ollama.';
} finally {
assistantEl.classList.remove('ai-chat-msg-loading');
if (reply) history.push({ role: 'assistant', content: reply });
busy = false;
sendBtn.disabled = false;
inputEl.focus();
}
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
// Auto-grow textarea
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
});
}

View File

@@ -0,0 +1,134 @@
export default async function setupFinance(el) {
const resolveColor = (cssVar, fallback) => {
const div = document.createElement('div');
div.style.color = fallback;
div.style.color = `var(${cssVar})`;
div.style.display = 'none';
document.body.appendChild(div);
const color = getComputedStyle(div).color;
document.body.removeChild(div);
return color;
};
const addAlpha = (rgb, a) => {
const m = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
return m ? `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${a})` : rgb;
};
let data = null;
let charts = {};
async function load() {
try {
const res = await fetch('/api/finance');
data = await res.json();
if (!data.monthly || !data.monthly.labels) {
data.monthly = { labels: ['Jan'], income: [0], expenses: [0], spend: [0] };
}
if (!data.accounts) data.accounts = [];
renderCharts();
renderTable();
} catch (e) {
console.error('Finance widget error:', e);
}
}
async function save() {
await fetch('/api/finance', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
}
function renderCharts() {
const cPos = resolveColor('--color-positive', 'hsl(150, 60%, 50%)');
const cNeg = resolveColor('--color-negative', 'hsl(0, 70%, 65%)');
const cPri = resolveColor('--color-primary', 'hsl(12, 70%, 60%)');
const cText = resolveColor('--color-text-subdue', 'rgba(255,255,255,0.6)');
Chart.defaults.color = cText;
Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif';
const c1 = el.querySelector('#incomeExpenseChart');
if (c1) {
if (charts.incomeExpense) charts.incomeExpense.destroy();
charts.incomeExpense = new Chart(c1.getContext('2d'), {
type: 'bar',
data: {
labels: data.monthly.labels,
datasets: [
{ label: 'Income', data: data.monthly.income, backgroundColor: addAlpha(cPos, 0.6), borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 },
{ label: 'Expenses', data: data.monthly.expenses, backgroundColor: addAlpha(cNeg, 0.6), borderColor: cNeg, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 }
]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, layout: { padding: 10 }, scales: { y: { display: false, beginAtZero: true }, x: { display: false } } }
});
}
const c2 = el.querySelector('#spendChart');
if (c2) {
if (charts.spend) charts.spend.destroy();
charts.spend = new Chart(c2.getContext('2d'), {
type: 'line',
data: {
labels: data.monthly.labels,
datasets: [{ label: 'Spend', data: data.monthly.spend, borderColor: cPri, borderWidth: 2, tension: 0.4, fill: false, pointRadius: 0, pointHoverRadius: 5 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, layout: { padding: 10 }, scales: { y: { display: false, beginAtZero: true }, x: { display: false } } }
});
}
}
function renderTable() {
const tbody = el.querySelector('#finance-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!data.accounts || data.accounts.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:2rem" class="color-subdue">No accounts.</td></tr>`;
return;
}
const inStyle = `width:100%;background:transparent;border:none;color:var(--color-text-highlight);padding:1rem;font-family:inherit;font-size:1.05rem;outline:none;transition:background 0.2s`;
data.accounts.forEach((acc, i) => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid var(--color-widget-content-border)';
const td1 = document.createElement('td'); td1.style.padding = '0';
td1.innerHTML = `<input style="${inStyle}" value="${acc.name}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="this.closest('.widget').financeApp.update(${i},'name',this.value)">`;
const td2 = document.createElement('td'); td2.style.padding = '0';
td2.innerHTML = `<input type="number" step="0.01" style="${inStyle}" class="${acc.balance < 0 ? 'color-negative' : 'color-positive'}" value="${acc.balance}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="this.closest('.widget').financeApp.update(${i},'balance',parseFloat(this.value))">`;
const td3 = document.createElement('td'); td3.style.padding = '0';
td3.innerHTML = `<input style="${inStyle}" value="${acc.currency}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="this.closest('.widget').financeApp.update(${i},'currency',this.value)">`;
const td4 = document.createElement('td'); td4.style.textAlign = 'right'; td4.style.padding = '1rem';
td4.innerHTML = `<button onclick="this.closest('.widget').financeApp.delete(${i})" style="padding:0.4rem 0.8rem;border-radius:6px;border:none;background:rgba(255,50,50,0.15);color:var(--color-negative);font-weight:600;cursor:pointer">Delete</button>`;
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4);
tbody.appendChild(tr);
});
}
const app = {
update(i, key, val) { data.accounts[i][key] = val; renderTable(); save(); },
addAccount() { data.accounts.push({ name: 'New Account', balance: 0, currency: 'USD' }); renderTable(); save(); },
delete(i) { data.accounts.splice(i, 1); renderTable(); save(); }
};
// Attach app to widget element so inline event handlers can reach it
el.financeApp = app;
// Also expose the add-account button
const addBtn = el.querySelector('[data-finance-add]');
if (addBtn) addBtn.onclick = () => app.addAccount();
if (!window.Chart) {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
s.onload = () => load();
document.head.appendChild(s);
} else {
await load();
}
}

View File

@@ -653,6 +653,28 @@ async function setupTodos() {
}
}
async function setupFinances() {
const elems = Array.from(document.getElementsByClassName("personal-finance"));
if (elems.length == 0) return;
const finance = await import('./finance.js');
for (let i = 0; i < elems.length; i++) {
finance.default(elems[i]);
}
}
async function setupAIChats() {
const elems = Array.from(document.getElementsByClassName("ai-chat"));
if (elems.length == 0) return;
const aiChat = await import('./ai-chat.js');
for (let i = 0; i < elems.length; i++) {
aiChat.default(elems[i]);
}
}
function setupTruncatedElementTitles() {
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
@@ -757,6 +779,8 @@ async function setupPage() {
setupClocks()
await setupCalendars();
await setupTodos();
await setupFinances();
await setupAIChats();
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();

View File

@@ -0,0 +1,15 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="ai-chat" data-model="{{ .Model }}" data-ollama-url="{{ .OllamaURL }}">
<div class="ai-chat-messages"></div>
<div class="ai-chat-input-row">
<textarea class="ai-chat-input" placeholder="Ask anything..." rows="1"></textarea>
<button class="ai-chat-send" aria-label="Send">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path d="M3.105 2.289a.75.75 0 0 0-.826.95l1.414 4.925A1.5 1.5 0 0 0 5.135 9.25h6.115a.75.75 0 0 1 0 1.5H5.135a1.5 1.5 0 0 0-1.442 1.086l-1.414 4.926a.75.75 0 0 0 .826.95 28.896 28.896 0 0 0 15.293-7.154.75.75 0 0 0 0-1.115A28.897 28.897 0 0 0 3.105 2.289Z" />
</svg>
</button>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,42 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="personal-finance" style="display:flex;flex-direction:column;gap:1.5rem">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap">
<div style="flex:1;min-width:280px">
<p class="size-h5 color-subdue uppercase" style="margin-bottom:0.5rem">Income vs Expense</p>
<div style="position:relative;height:200px">
<canvas id="incomeExpenseChart"></canvas>
</div>
</div>
<div style="flex:1;min-width:280px">
<p class="size-h5 color-subdue uppercase" style="margin-bottom:0.5rem">Monthly Discretionary Spend</p>
<div style="position:relative;height:200px">
<canvas id="spendChart"></canvas>
</div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<p class="size-h5 color-subdue uppercase">Account Balances</p>
<button data-finance-add style="padding:0.4rem 0.8rem;border-radius:6px;border:none;background:var(--color-primary);color:var(--color-background);font-weight:600;cursor:pointer;text-transform:uppercase;letter-spacing:0.1em;font-size:0.75rem">+ Add Account</button>
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;text-align:left">
<thead>
<tr style="border-bottom:2px solid var(--color-widget-content-border)">
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Account</th>
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Balance</th>
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Currency</th>
<th style="padding:0.6rem 1rem;text-align:right" class="size-h5 color-subdue uppercase">Actions</th>
</tr>
</thead>
<tbody id="finance-tbody"></tbody>
</table>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,77 @@
package glance
import (
"bytes"
"encoding/json"
"html/template"
"io"
"net/http"
)
var aiChatWidgetTemplate = mustParseTemplate("ai-chat.html", "widget-base.html")
type aiChatWidget struct {
widgetBase `yaml:",inline"`
OllamaURL string `yaml:"ollama-url"`
Model string `yaml:"model"`
cachedHTML template.HTML `yaml:"-"`
}
func (w *aiChatWidget) initialize() error {
w.withTitle("AI Assistant").withError(nil)
if w.OllamaURL == "" {
w.OllamaURL = "http://localhost:11434"
}
if w.Model == "" {
w.Model = "llama3.2"
}
w.cachedHTML = w.renderTemplate(w, aiChatWidgetTemplate)
return nil
}
func (w *aiChatWidget) Render() template.HTML {
return w.cachedHTML
}
func handleAIChatProxy(resp http.ResponseWriter, req *http.Request) {
var body struct {
OllamaURL string `json:"ollama_url"`
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
http.Error(resp, err.Error(), http.StatusBadRequest)
return
}
if body.OllamaURL == "" {
body.OllamaURL = "http://localhost:11434"
}
ollamaReq := map[string]interface{}{
"model": body.Model,
"messages": body.Messages,
"stream": true,
}
ollamaBody, err := json.Marshal(ollamaReq)
if err != nil {
http.Error(resp, err.Error(), http.StatusInternalServerError)
return
}
ollamaResp, err := http.Post(body.OllamaURL+"/api/chat", "application/json", bytes.NewReader(ollamaBody))
if err != nil {
http.Error(resp, "could not reach Ollama: "+err.Error(), http.StatusBadGateway)
return
}
defer ollamaResp.Body.Close()
resp.Header().Set("Content-Type", "application/x-ndjson")
resp.Header().Set("X-Content-Type-Options", "nosniff")
io.Copy(resp, ollamaResp.Body)
}

View File

@@ -0,0 +1,20 @@
package glance
import "html/template"
var personalFinanceWidgetTemplate = mustParseTemplate("personal-finance.html", "widget-base.html")
type personalFinanceWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
}
func (widget *personalFinanceWidget) initialize() error {
widget.withTitle("Personal Finance").withError(nil)
widget.cachedHTML = widget.renderTemplate(widget, personalFinanceWidgetTemplate)
return nil
}
func (widget *personalFinanceWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -81,6 +81,10 @@ func newWidget(widgetType string) (widget, error) {
w = &serverStatsWidget{}
case "to-do":
w = &todoWidget{}
case "personal-finance":
w = &personalFinanceWidget{}
case "ai-chat":
w = &aiChatWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}