diff --git a/.gitignore b/.gitignore index 00cbbdf53f..b9ae3520aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,30 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov +# Build outputs +bin/ +obj/ -# Coverage directory used by tools like istanbul -coverage +# User-specific files +*.user +*.suo +*.userosscache +*.sln.docstates -# nyc test coverage -.nyc_output +# Rider +.idea/ -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt +# VS Code +.vscode/ -# Bower dependency directory (https://bower.io/) -bower_components +# ASP.NET Core secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json -# node-waf configuration -.lock-wscript +# Logs +*.log -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release +# OS +.DS_Store +Thumbs.db -# Dependency directories +# Node (optional tooling) node_modules/ -jspm_packages/ - -# Typescript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - diff --git a/ExpenseTracker.csproj b/ExpenseTracker.csproj new file mode 100644 index 0000000000..24ec0e89fb --- /dev/null +++ b/ExpenseTracker.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000000..a1d573b163 --- /dev/null +++ b/Program.cs @@ -0,0 +1,114 @@ +using System.Globalization; + +var builder = WebApplication.CreateBuilder(args); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +var transactions = new List +{ + new( + Id: Guid.NewGuid(), + Title: "Morning coffee", + Amount: -4.5m, + Category: "Food & Drink", + Tags: new List { "Cafe", "Daily" }, + Location: "Downtown", + Date: DateTimeOffset.Now.AddHours(-5), + PaymentMethod: "Card" + ), + new( + Id: Guid.NewGuid(), + Title: "Metro pass", + Amount: -22m, + Category: "Transport", + Tags: new List { "Commute" }, + Location: "Central Station", + Date: DateTimeOffset.Now.AddDays(-1), + PaymentMethod: "Card" + ), + new( + Id: Guid.NewGuid(), + Title: "Freelance invoice", + Amount: 540m, + Category: "Income", + Tags: new List { "Client" }, + Location: "Remote", + Date: DateTimeOffset.Now.AddDays(-2), + PaymentMethod: "Bank" + ) +}; + +app.MapGet("/api/transactions", () => Results.Ok(transactions.OrderByDescending(t => t.Date))); + +app.MapPost("/api/transactions", (TransactionInput input) => +{ + var transaction = new Transaction( + Id: Guid.NewGuid(), + Title: input.Title, + Amount: input.Amount, + Category: input.Category, + Tags: input.Tags ?? new List(), + Location: input.Location ?? "", + Date: input.Date ?? DateTimeOffset.Now, + PaymentMethod: input.PaymentMethod ?? "" + ); + + transactions.Add(transaction); + + return Results.Created($"/api/transactions/{transaction.Id}", transaction); +}); + +app.MapPost("/api/transcribe", (TranscriptionRequest request) => +{ + if (string.IsNullOrWhiteSpace(request.Transcript)) + { + return Results.BadRequest(new { message = "No audio transcript received." }); + } + + var normalized = request.Transcript.Trim(); + var suggestion = normalized.Contains("coffee", StringComparison.OrdinalIgnoreCase) + ? new TransactionSuggestion("Coffee", -4.5m, "Food & Drink") + : new TransactionSuggestion("Voice entry", -12m, "Everyday"); + + return Results.Ok(new + { + transcript = normalized, + suggestion, + confidence = 0.82 + }); +}); + +app.MapFallbackToFile("/index.html"); + +app.Run(); + +record Transaction( + Guid Id, + string Title, + decimal Amount, + string Category, + List Tags, + string Location, + DateTimeOffset Date, + string PaymentMethod +) +{ + public string DateLabel => Date.ToString("MMM d, yyyy", CultureInfo.InvariantCulture); +} + +record TransactionInput( + string Title, + decimal Amount, + string Category, + List? Tags, + string? Location, + DateTimeOffset? Date, + string? PaymentMethod +); + +record TranscriptionRequest(string Transcript); + +record TransactionSuggestion(string Title, decimal Amount, string Category); diff --git a/README.md b/README.md index 4f70029163..a60a9cfe97 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,19 @@ -## Try it +# Everyday Expense Tracker (.NET) -[https://my-json-server.typicode.com/typicode/demo](https://my-json-server.typicode.com/typicode/demo) +A minimal, voice-forward expense tracker prototype built with ASP.NET Core and a lightweight HTML/CSS/JS UI. -## Use your own data +## Features +- Period stats for today, week, and month +- Transaction history with quick filters +- Add expense/income with categories, tags, and location +- Voice capture flow with confirmation + retry -Fork it and change `db.json` values or create a repo with a `db.json` file. +## Run locally +```bash +dotnet run +``` +Then open `http://localhost:5000` (or the URL shown in the console). + +## Notes +- Voice capture uses the browser Speech Recognition API when available. +- `/api/transcribe` is a minimal stub that returns a suggestion from the transcript. diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/db.json b/db.json deleted file mode 100644 index cbb4be0cc6..0000000000 --- a/db.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "users": [ - { "id": 1, "userName": "test1", "password": "123" }, - { "id": 2, "userName": "test2", "password": "123" }, - { "id": 3, "userName": "qwerty", "password": "123" } - ], - "events": [ - { "id": 1, "eventName": ".Net Community", "location": "Lviv", "evtTp":"public", "Date": "2019-11-15T19:43:37+0100", "organizer": "Epam" }, - { "id": 2, "eventName": "Angular Intro", "location": "Krakiv", "evtTp":"private", "Date": "2019-10-14T19:43:37+0100", "organizer": "Kinetik" }, - { "id": 3, "eventName": "Unit/Integration tests", "location": "Kyiv", "evtTp":"internal", "Date": "2019-11-04T19:43:37+0100", "organizer": "SoftServe" } - ] -} diff --git a/wwwroot/app.js b/wwwroot/app.js new file mode 100644 index 0000000000..278a7e0972 --- /dev/null +++ b/wwwroot/app.js @@ -0,0 +1,289 @@ +const state = { + transactions: [ + { + id: crypto.randomUUID(), + title: "Morning coffee", + amount: -4.5, + category: "Food & Drink", + tags: ["Cafe", "Daily"], + location: "Downtown", + date: new Date(), + }, + { + id: crypto.randomUUID(), + title: "Metro pass", + amount: -22, + category: "Transport", + tags: ["Commute"], + location: "Central Station", + date: new Date(Date.now() - 86400000), + }, + { + id: crypto.randomUUID(), + title: "Freelance invoice", + amount: 540, + category: "Income", + tags: ["Client"], + location: "Remote", + date: new Date(Date.now() - 172800000), + }, + ], + filter: "all", + voice: { + transcript: "", + suggestion: null, + recognition: null, + }, +}; + +const transactionList = document.getElementById("transactionList"); +const statToday = document.getElementById("statToday"); +const statWeek = document.getElementById("statWeek"); +const statMonth = document.getElementById("statMonth"); +const addDialog = document.getElementById("addDialog"); +const addForm = document.getElementById("addForm"); + +const voiceStatus = document.getElementById("voiceStatus"); +const voicePreview = document.getElementById("voicePreview"); +const voiceStart = document.getElementById("voiceStart"); +const voiceStop = document.getElementById("voiceStop"); +const voiceConfirm = document.getElementById("voiceConfirm"); +const voiceRetry = document.getElementById("voiceRetry"); + +const filters = document.querySelectorAll(".chip"); + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +const formatAmount = (amount) => + `${amount < 0 ? "-" : "+"}${currency.format(Math.abs(amount))}`; + +const formatDate = (date) => + date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + +const filterTransactions = () => { + const now = new Date(); + return state.transactions.filter((transaction) => { + if (state.filter === "today") { + return transaction.date.toDateString() === now.toDateString(); + } + if (state.filter === "week") { + const weekAgo = new Date(now.getTime() - 6 * 86400000); + return transaction.date >= weekAgo; + } + return true; + }); +}; + +const renderTransactions = () => { + const list = filterTransactions(); + transactionList.innerHTML = ""; + + list + .sort((a, b) => b.date - a.date) + .forEach((transaction) => { + const card = document.createElement("div"); + card.className = "transaction"; + + const meta = document.createElement("div"); + meta.className = "meta"; + meta.innerHTML = ` + ${transaction.title} + ${transaction.category} • ${transaction.location || "Remote"} • ${formatDate( + transaction.date + )} + `; + + const amount = document.createElement("div"); + amount.className = `amount ${transaction.amount < 0 ? "negative" : "positive"}`; + amount.textContent = formatAmount(transaction.amount); + + card.append(meta, amount); + transactionList.appendChild(card); + }); +}; + +const sumRange = (days) => { + const now = new Date(); + const start = new Date(now.getTime() - days * 86400000); + return state.transactions + .filter((transaction) => transaction.date >= start) + .reduce((sum, transaction) => sum + transaction.amount, 0); +}; + +const renderStats = () => { + statToday.textContent = currency.format(sumRange(1)); + statWeek.textContent = currency.format(sumRange(7)); + statMonth.textContent = currency.format(sumRange(30)); +}; + +const openDialog = () => addDialog.showModal(); +const closeDialog = () => addDialog.close(); + +const resetVoice = () => { + state.voice.transcript = ""; + state.voice.suggestion = null; + voicePreview.textContent = ""; + voiceConfirm.disabled = true; + voiceRetry.disabled = true; + voiceStatus.textContent = "Ready for input."; +}; + +const applySuggestion = (suggestion) => { + state.voice.suggestion = suggestion; + voicePreview.innerHTML = ` + ${suggestion.title} + ${suggestion.category} • ${formatAmount(suggestion.amount)} + `; + voiceConfirm.disabled = false; + voiceRetry.disabled = false; +}; + +const requestTranscription = async (transcript) => { + try { + const response = await fetch("/api/transcribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ transcript }), + }); + + if (!response.ok) { + throw new Error("Transcription failed"); + } + + const data = await response.json(); + applySuggestion(data.suggestion); + } catch (error) { + voiceStatus.textContent = "Transcription failed. Please record again."; + voiceRetry.disabled = false; + } +}; + +const setupVoice = () => { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + voiceStatus.textContent = + "Voice capture is not supported in this browser."; + voiceStart.disabled = true; + return; + } + + const recognition = new SpeechRecognition(); + recognition.lang = "en-US"; + recognition.interimResults = false; + recognition.continuous = false; + state.voice.recognition = recognition; + + recognition.onstart = () => { + voiceStatus.textContent = "Listening..."; + voiceStart.disabled = true; + voiceStop.disabled = false; + }; + + recognition.onresult = (event) => { + const transcript = event.results[0][0].transcript; + state.voice.transcript = transcript; + voiceStatus.textContent = "Processing transcript..."; + voicePreview.textContent = transcript; + requestTranscription(transcript); + }; + + recognition.onerror = () => { + voiceStatus.textContent = "We hit an error. Try recording again."; + voiceRetry.disabled = false; + voiceStart.disabled = false; + voiceStop.disabled = true; + }; + + recognition.onend = () => { + voiceStop.disabled = true; + voiceStart.disabled = false; + }; +}; + +const addTransaction = (payload) => { + state.transactions.push({ + ...payload, + id: crypto.randomUUID(), + date: new Date(), + }); + renderStats(); + renderTransactions(); +}; + +addForm.addEventListener("submit", (event) => { + event.preventDefault(); + const formData = new FormData(addForm); + const tags = formData + .get("tags") + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + + addTransaction({ + title: formData.get("title"), + amount: Number(formData.get("amount")), + category: formData.get("category"), + tags, + location: formData.get("location"), + }); + + addForm.reset(); + closeDialog(); +}); + +filters.forEach((chip) => { + chip.addEventListener("click", () => { + filters.forEach((item) => item.classList.remove("active")); + chip.classList.add("active"); + state.filter = chip.dataset.range; + renderTransactions(); + }); +}); + +voiceStart.addEventListener("click", () => { + resetVoice(); + state.voice.recognition?.start(); +}); + +voiceStop.addEventListener("click", () => { + state.voice.recognition?.stop(); +}); + +voiceRetry.addEventListener("click", () => { + resetVoice(); +}); + +voiceConfirm.addEventListener("click", () => { + if (!state.voice.suggestion) { + return; + } + + addTransaction({ + title: state.voice.suggestion.title, + amount: state.voice.suggestion.amount, + category: state.voice.suggestion.category, + tags: ["Voice"], + location: "Audio capture", + }); + + resetVoice(); +}); + +document.getElementById("openAdd").addEventListener("click", openDialog); +document.getElementById("closeAdd").addEventListener("click", closeDialog); +document.getElementById("cancelAdd").addEventListener("click", closeDialog); +document.getElementById("openVoice").addEventListener("click", () => { + document.getElementById("voicePanel").scrollIntoView({ behavior: "smooth" }); +}); + +renderStats(); +renderTransactions(); +setupVoice(); diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000000..0df404c7f5 --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,122 @@ + + + + + + Everyday Expense Tracker + + + + + + + Everyday tracker + Overview & AI voice capture + + Track spending with clear stats, quick add, and voice-first capture. + + + + Add transaction + Voice entry + + + + + + Today + $0 + 2 transactions + + + This week + $0 + 7 transactions + + + This month + $0 + 18 transactions + + + + + + + Recent activity + + All + Today + Week + + + + + + + + + + + + + Add transaction + ✕ + + + + Title + + + + Amount + + + + Category + + Everyday + Food & Drink + Transport + Home + Income + + + + Tags + + + + Location + + + + Payment method + + + + + Cancel + Save + + + + + + + diff --git a/wwwroot/styles.css b/wwwroot/styles.css new file mode 100644 index 0000000000..834557b3f5 --- /dev/null +++ b/wwwroot/styles.css @@ -0,0 +1,296 @@ +:root { + color-scheme: light; + --bg: #f7f7fb; + --panel: #ffffff; + --text: #1e1f26; + --muted: #5c5e70; + --primary: #2f5bea; + --border: #e3e5ee; + --shadow: 0 14px 40px rgba(30, 31, 38, 0.08); + font-family: "Inter", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +.app { + max-width: 1100px; + margin: 0 auto; + padding: 32px 24px 48px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; +} + +.hero h1 { + margin: 8px 0; + font-size: 32px; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 12px; + color: var(--muted); + margin: 0; +} + +.subtext { + margin: 0; + color: var(--muted); +} + +.hero-actions { + display: flex; + gap: 12px; +} + +.btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text); + padding: 10px 16px; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + transition: 0.2s ease; +} + +.btn.primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; + box-shadow: 0 10px 20px rgba(47, 91, 234, 0.25); +} + +.btn.ghost:hover, +.btn.primary:hover { + transform: translateY(-1px); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.card { + background: var(--panel); + border-radius: 20px; + padding: 18px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 4px; +} + +.label { + color: var(--muted); + font-size: 13px; + margin: 0; +} + +.card h2 { + margin: 0; + font-size: 26px; +} + +.hint { + font-size: 12px; + color: var(--muted); +} + +.content { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; +} + +.panel { + background: var(--panel); + border-radius: 24px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.filters { + display: flex; + gap: 8px; +} + +.chip { + border: 1px solid var(--border); + background: transparent; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + cursor: pointer; +} + +.chip.active { + background: #eff3ff; + border-color: #d5dcff; + color: var(--primary); +} + +.transaction-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.transaction { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 16px; + background: #fafbff; + border: 1px solid #edf0ff; +} + +.transaction .meta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.transaction .meta span { + color: var(--muted); + font-size: 12px; +} + +.amount { + font-weight: 700; +} + +.amount.negative { + color: #dc3545; +} + +.amount.positive { + color: #1b8a5a; +} + +.voice-panel { + gap: 12px; +} + +.voice-controls, +.voice-actions { + display: flex; + gap: 12px; +} + +.voice-status { + padding: 10px 12px; + border-radius: 12px; + background: #f1f2f8; + font-size: 13px; + color: var(--muted); +} + +.voice-preview { + min-height: 72px; + border: 1px dashed var(--border); + border-radius: 16px; + padding: 12px; + font-size: 14px; +} + +.modal { + border: none; + border-radius: 24px; + padding: 0; + width: min(520px, 92vw); +} + +.modal::backdrop { + background: rgba(18, 19, 24, 0.35); +} + +.modal-content { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.icon-btn { + border: none; + background: #f1f2f8; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +input, +select { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + font-size: 14px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +@media (max-width: 900px) { + .hero { + flex-direction: column; + align-items: flex-start; + } + + .content { + grid-template-columns: 1fr; + } +}
Everyday tracker
+ Track spending with clear stats, quick add, and voice-first capture. +
Today
This week
This month