From 08eefe29732972430537a5f274b028026ea00fb1 Mon Sep 17 00:00:00 2001 From: Ross Miller Date: Sun, 1 Mar 2026 21:19:34 +0000 Subject: [PATCH] Updates --- .github/workflows/docker-publish.yml | 18 ++- BudgetApp/Components/Layout/MainLayout.razor | 2 + BudgetApp/Components/Layout/NavMenu.razor | 48 +++++- BudgetApp/Components/Layout/NavMenu.razor.css | 67 ++++++++ BudgetApp/Components/Pages/Accounts.razor | 3 +- BudgetApp/Components/Pages/Categories.razor | 46 +++--- BudgetApp/Components/Pages/Home.razor | 146 +++++++----------- BudgetApp/Components/Pages/Home.razor.css | 8 + BudgetApp/Components/Pages/Settings.razor | 5 +- BudgetApp/Components/Pages/Transactions.razor | 121 ++++++++++++++- BudgetApp/Components/ToastContainer.razor | 51 ++++++ BudgetApp/Components/ToastContainer.razor.css | 61 ++++++++ BudgetApp/Components/ToastValidator.razor | 47 ++++++ BudgetApp/Program.cs | 1 + BudgetApp/Services/ToastService.cs | 8 + BudgetApp/Services/TransactionService.cs | 33 +++- BudgetApp/budgetapp.db-shm | Bin 32768 -> 32768 bytes BudgetApp/budgetapp.db-wal | Bin 819912 -> 1153632 bytes BudgetApp/wwwroot/app.css | 42 +++-- Dockerfile | 7 +- 20 files changed, 572 insertions(+), 142 deletions(-) create mode 100644 BudgetApp/Components/Pages/Home.razor.css create mode 100644 BudgetApp/Components/ToastContainer.razor create mode 100644 BudgetApp/Components/ToastContainer.razor.css create mode 100644 BudgetApp/Components/ToastValidator.razor create mode 100644 BudgetApp/Services/ToastService.cs diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 95bed93..686a041 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,13 +41,25 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer + tags: | + type=raw,value=latest + type=sha,prefix= + type=raw,value={{date 'YYYY.MM.DD'}} + labels: | + org.opencontainers.image.title=Budgeteer + org.opencontainers.image.description=A personal finance dashboard built with Blazor Server (.NET 10). Multi-account support, recurring transactions, category management, balance trend charts, and ASP.NET Identity authentication. + - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true - tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:latest - ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/BudgetApp/Components/Layout/MainLayout.razor b/BudgetApp/Components/Layout/MainLayout.razor index 7ab6ccb..6ba0afc 100644 --- a/BudgetApp/Components/Layout/MainLayout.razor +++ b/BudgetApp/Components/Layout/MainLayout.razor @@ -21,6 +21,8 @@ + +
An unhandled error has occurred. Reload diff --git a/BudgetApp/Components/Layout/NavMenu.razor b/BudgetApp/Components/Layout/NavMenu.razor index 913d3af..9ef0852 100644 --- a/BudgetApp/Components/Layout/NavMenu.razor +++ b/BudgetApp/Components/Layout/NavMenu.razor @@ -1,4 +1,8 @@ -
- +
-
diff --git a/BudgetApp/Components/Pages/Categories.razor b/BudgetApp/Components/Pages/Categories.razor index cff9346..669a56e 100644 --- a/BudgetApp/Components/Pages/Categories.razor +++ b/BudgetApp/Components/Pages/Categories.razor @@ -14,12 +14,11 @@
- +
-
@@ -49,31 +48,38 @@ else {
- @if (editingCategoryId == category.Id) - { -
+
+ @if (editingCategoryId == category.Id) + { - -
+
+ +
+ } + else + { +
@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)
+
+
@category.Name
+
+ } +
+ - } - else - { -
-
@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)
-
@category.Name
-
- - } +
} diff --git a/BudgetApp/Components/Pages/Home.razor b/BudgetApp/Components/Pages/Home.razor index 77df351..34686ea 100644 --- a/BudgetApp/Components/Pages/Home.razor +++ b/BudgetApp/Components/Pages/Home.razor @@ -2,7 +2,6 @@ @attribute [Authorize] @inject DashboardService DashboardService @inject TransactionService TransactionService -@inject AccountService AccountService @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JS @implements IAsyncDisposable @@ -25,53 +24,25 @@ else if (data.Accounts.Count == 0) } else { -
- @foreach (var account in data.Accounts) - { -
- @account.Name - @if (editingBalanceAccountId == account.Id) - { -
- @account.GetSymbol() - - - -
- } - else - { -
- - @account.FormatAmount(account.CurrentBalance) - -
- } -
- } -
- -
- Window: -
- - - - -
- - to - -
-
+
+
Balance Trend
+
+
+ + + + +
+ + to + +
+
-
Balance Trend
-
+
@@ -88,12 +59,11 @@ else
- + @@ -341,6 +319,7 @@ else // Modal state private bool showModal; private int? editingTransactionId; + private bool editIsPartOfSeries; private ModalTransactionForm modalForm = new(); // Chart redraw tracking @@ -422,30 +401,6 @@ else } } - // Balance editing state - private int? editingBalanceAccountId; - private decimal editingBalanceValue; - - private void StartBalanceEdit(Account account) - { - editingBalanceAccountId = account.Id; - editingBalanceValue = account.CurrentBalance; - } - - private void CancelBalanceEdit() - { - editingBalanceAccountId = null; - } - - private async Task SaveBalance(int accountId) - { - if (userId is null) return; - - await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue); - editingBalanceAccountId = null; - await RefreshData(); - } - private void OpenAddModal() { editingTransactionId = null; @@ -459,6 +414,7 @@ else private void OpenEditModal(Transaction t) { editingTransactionId = t.Id; + editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null; modalForm = new ModalTransactionForm { Description = t.Description, @@ -493,6 +449,18 @@ else RecurrenceEndDate = modalForm.RecurrenceEndDate }; await TransactionService.CreateTransactionAsync(transaction); + await RefreshData(); + + // Keep modal open and reset form for the next entry + var previousAccountId = modalForm.AccountId; + var previousCategoryId = modalForm.CategoryId; + var previousType = modalForm.Type; + modalForm = new ModalTransactionForm + { + AccountId = previousAccountId, + CategoryId = previousCategoryId, + Type = previousType + }; } else { @@ -501,10 +469,10 @@ else modalForm.Description, modalForm.Amount, modalForm.Date, modalForm.Type, modalForm.AccountId, modalForm.CategoryId, modalForm.RecurrenceType, modalForm.RecurrenceEndDate); - } - showModal = false; - await RefreshData(); + showModal = false; + await RefreshData(); + } } // Delete confirmation state diff --git a/BudgetApp/Components/Pages/Home.razor.css b/BudgetApp/Components/Pages/Home.razor.css new file mode 100644 index 0000000..83c7cc4 --- /dev/null +++ b/BudgetApp/Components/Pages/Home.razor.css @@ -0,0 +1,8 @@ +.date-picker { + width: auto; +} + +.chart-container { + height: clamp(200px, 40vw, 350px); + position: relative; +} diff --git a/BudgetApp/Components/Pages/Settings.razor b/BudgetApp/Components/Pages/Settings.razor index 9c3e2e0..9f60069 100644 --- a/BudgetApp/Components/Pages/Settings.razor +++ b/BudgetApp/Components/Pages/Settings.razor @@ -30,22 +30,19 @@ } - +
-
-
-
diff --git a/BudgetApp/Components/Pages/Transactions.razor b/BudgetApp/Components/Pages/Transactions.razor index f86eda1..d99e4cd 100644 --- a/BudgetApp/Components/Pages/Transactions.razor +++ b/BudgetApp/Components/Pages/Transactions.razor @@ -24,12 +24,11 @@ else { - +
-
@@ -86,7 +85,6 @@
-
}
@@ -174,6 +172,9 @@ else @(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount) + @@ -209,6 +210,79 @@ else
} + + @if (showEditModal) + { + + } } @code { @@ -319,6 +393,47 @@ else private bool showDeleteModal; private Transaction? pendingDeleteTransaction; + // Edit modal state + private bool showEditModal; + private int? editingTransactionId; + private bool editIsPartOfSeries; + private TransactionForm editModel = new(); + + private void OpenEditModal(Transaction t) + { + editingTransactionId = t.Id; + editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null; + editModel = new TransactionForm + { + Description = t.Description, + Amount = t.Amount, + Date = t.Date, + Type = t.Type, + AccountId = t.AccountId, + CategoryId = t.CategoryId, + RecurrenceType = t.RecurrenceType, + RecurrenceEndDate = t.RecurrenceEndDate + }; + showEditModal = true; + } + + private void CloseEditModal() => showEditModal = false; + + private async Task SaveEdit() + { + if (userId is null || editingTransactionId is null) return; + + await TransactionService.UpdateTransactionAsync( + editingTransactionId.Value, userId, + editModel.Description, editModel.Amount, editModel.Date, + editModel.Type, editModel.AccountId, editModel.CategoryId, + editModel.RecurrenceType, editModel.RecurrenceEndDate); + + showEditModal = false; + editingTransactionId = null; + await LoadData(); + } + private void ConfirmDelete(Transaction t) { pendingDeleteTransaction = t; diff --git a/BudgetApp/Components/ToastContainer.razor b/BudgetApp/Components/ToastContainer.razor new file mode 100644 index 0000000..7bdd182 --- /dev/null +++ b/BudgetApp/Components/ToastContainer.razor @@ -0,0 +1,51 @@ +@inject ToastService ToastService +@implements IDisposable +@rendermode InteractiveServer + +
+ @foreach (var toast in toasts) + { +
+ ⚠ + @toast.Message +
+ } +
+ +@code { + private readonly List toasts = []; + + protected override void OnInitialized() + { + ToastService.OnShow += ShowToast; + } + + private async void ShowToast(string message) + { + var toast = new ToastItem { Message = message }; + toasts.Add(toast); + await InvokeAsync(StateHasChanged); + _ = DismissAfterDelay(toast); + } + + private async Task DismissAfterDelay(ToastItem toast) + { + await Task.Delay(3500); + toast.Dismissing = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + toasts.Remove(toast); + await InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + ToastService.OnShow -= ShowToast; + } + + private sealed class ToastItem + { + public string Message { get; set; } = ""; + public bool Dismissing { get; set; } + } +} diff --git a/BudgetApp/Components/ToastContainer.razor.css b/BudgetApp/Components/ToastContainer.razor.css new file mode 100644 index 0000000..f7c0fa4 --- /dev/null +++ b/BudgetApp/Components/ToastContainer.razor.css @@ -0,0 +1,61 @@ +.toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1080; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast-item { + display: flex; + align-items: center; + gap: 0.5rem; + background: #dc3545; + color: white; + padding: 0.75rem 1rem; + border-radius: 0.375rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: toastSlideIn 0.3s ease; + pointer-events: auto; + max-width: 400px; + font-size: 0.875rem; +} + +.toast-dismiss { + animation: toastSlideOut 0.3s ease forwards; +} + +.toast-icon { + font-size: 1.1rem; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + min-width: 0; +} + +@keyframes toastSlideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toastSlideOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} diff --git a/BudgetApp/Components/ToastValidator.razor b/BudgetApp/Components/ToastValidator.razor new file mode 100644 index 0000000..3a7ebe3 --- /dev/null +++ b/BudgetApp/Components/ToastValidator.razor @@ -0,0 +1,47 @@ +@implements IDisposable + + + +@code { + [CascadingParameter] + private EditContext? CurrentEditContext { get; set; } + + [Inject] + private ToastService ToastService { get; set; } = default!; + + private bool validationRequested; + + protected override void OnInitialized() + { + if (CurrentEditContext is not null) + { + CurrentEditContext.OnValidationRequested += OnValidationRequested; + CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged; + } + } + + private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e) + { + validationRequested = true; + } + + private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e) + { + if (!validationRequested || CurrentEditContext is null) return; + validationRequested = false; + + foreach (var message in CurrentEditContext.GetValidationMessages()) + { + ToastService.Show(message); + } + } + + public void Dispose() + { + if (CurrentEditContext is not null) + { + CurrentEditContext.OnValidationRequested -= OnValidationRequested; + CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged; + } + } +} diff --git a/BudgetApp/Program.cs b/BudgetApp/Program.cs index 1ad43fa..1baa51d 100644 --- a/BudgetApp/Program.cs +++ b/BudgetApp/Program.cs @@ -39,6 +39,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); diff --git a/BudgetApp/Services/ToastService.cs b/BudgetApp/Services/ToastService.cs new file mode 100644 index 0000000..e4ed236 --- /dev/null +++ b/BudgetApp/Services/ToastService.cs @@ -0,0 +1,8 @@ +namespace BudgetApp.Services; + +public sealed class ToastService +{ + public event Action? OnShow; + + public void Show(string message) => OnShow?.Invoke(message); +} diff --git a/BudgetApp/Services/TransactionService.cs b/BudgetApp/Services/TransactionService.cs index 1a15d89..b631057 100644 --- a/BudgetApp/Services/TransactionService.cs +++ b/BudgetApp/Services/TransactionService.cs @@ -116,14 +116,43 @@ public class TransactionService(ApplicationDbContext db) if (transaction is null) return; + var isPartOfSeries = transaction.RecurrenceType != RecurrenceType.None + || transaction.ParentTransactionId is not null; + transaction.Description = description; transaction.Amount = amount; transaction.Date = date; transaction.Type = type; transaction.AccountId = accountId; transaction.CategoryId = categoryId; - transaction.RecurrenceType = recurrenceType; - transaction.RecurrenceEndDate = recurrenceEndDate; + + if (isPartOfSeries) + { + // Cascade edits to all future occurrences in the series + var parentId = transaction.ParentTransactionId ?? transaction.Id; + var cutoffDate = transaction.Date.Date; + + var futureSiblings = await _db.Transactions + .Where(t => (t.Id == parentId || t.ParentTransactionId == parentId) + && t.Id != id + && t.Date.Date >= cutoffDate) + .ToListAsync(); + + foreach (var sibling in futureSiblings) + { + sibling.Description = description; + sibling.Amount = amount; + sibling.Type = type; + sibling.AccountId = accountId; + sibling.CategoryId = categoryId; + } + } + else + { + // Standalone transaction — preserve existing recurrence state (None) + transaction.RecurrenceType = recurrenceType; + transaction.RecurrenceEndDate = recurrenceEndDate; + } await _db.SaveChangesAsync(); } diff --git a/BudgetApp/budgetapp.db-shm b/BudgetApp/budgetapp.db-shm index 87f06e1fbbb079b2929832feb39fd4b088aabc3e..99b6a1ccb3a05ba9299ec7579c57111a2669428d 100644 GIT binary patch delta 510 zcmb7AJ1j$C7(L&;ZuN!YYMU}CNfWV4Fm$j;By=XkWV9JwiOF{Ji*#*bVG{$XL>e)8 z*ZWn^dQ+;@yVUYpsoNQ?gn+_HfX&H=o9jrQR_d-F9qOIv z?CJ>_yknl)*i}Py!+-OlTR`-tGw`#80Tyn6HOYk|eyhxyWg}~ovodKi^MHlZp(iR> zqg2L9$oN+<^C(m6e%tSfo>Ya@2c6_*Ub$FLddNK*kmEIM-Ro0Vv4uSx;Q=3z$w+ym z5_kQQ62ugixhyA^sw=CS!7S!5j|D8^6lXZc1uk)gYuq4)TihXzdnE9T7rf#PY5rU* z<%qI=2aC11Y)e?dCL(x;M7dNboMB?2R-d|tZS3P1k4PblOk|@XVgY$t8Ak|Vo(mJ3 xZ1<__*uep!Na7O;nJJ&_qGdo~!498m1G_lH37+r;A_HZUg$l?)PLbKQe**v_oDKj0 delta 236 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 diff --git a/BudgetApp/wwwroot/app.css b/BudgetApp/wwwroot/app.css index c3deba7..c217d23 100644 --- a/BudgetApp/wwwroot/app.css +++ b/BudgetApp/wwwroot/app.css @@ -1,5 +1,6 @@ html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + scrollbar-gutter: stable; } a, .btn-link { @@ -29,11 +30,21 @@ h1:focus { } .invalid { - outline: 1px solid #e50000; + outline: 2px solid #dc3545; + outline-offset: -2px; +} + +input.invalid:not([type="checkbox"]):not([type="radio"]):not([type="color"]), +textarea.invalid { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23dc3545' viewBox='0 0 16 16'%3E%3Cpath d='M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + background-size: 1rem 1rem; + padding-right: 2.25rem; } .validation-message { - color: #e50000; + display: none; } .blazor-error-boundary { @@ -83,23 +94,13 @@ h1:focus { /* Cards */ .card { - transition: box-shadow 0.25s ease, transform 0.25s ease; + transition: box-shadow 0.25s ease; } .card:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); } -/* Balance chips */ -.balance-chip { - transition: box-shadow 0.25s ease, transform 0.25s ease; -} - -.balance-chip:hover { - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); -} - /* List group items */ .list-group-item { transition: background-color 0.2s ease, border-left-color 0.2s ease, box-shadow 0.2s ease; @@ -121,12 +122,19 @@ h1:focus { /* Modal */ .modal.d-block { animation: modalFadeIn 0.2s ease; + overflow-y: auto; } .modal.d-block .modal-content { animation: modalSlideDown 0.3s ease; } +/* Prevent body scroll shift when modal is open */ +body:has(.modal.d-block) { + overflow: hidden; + padding-right: var(--scrollbar-width, 0px); +} + /* Buttons */ .btn { transition: transform 0.15s ease, box-shadow 0.15s ease, @@ -136,6 +144,9 @@ h1:focus { .btn:active { transform: scale(0.96); + /* Scale from center so layout dimensions stay fixed */ + transform-origin: center; + will-change: transform; } /* Nav tabs */ @@ -156,4 +167,9 @@ h1:focus { /* Chart canvas */ canvas { transition: opacity 0.3s ease; +} + +/* Prevent scrollbar appearance from shifting content width */ +.card-body[style*="overflow-y"] { + scrollbar-gutter: stable; } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 394ef4e..49dec8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,13 @@ FROM base AS final WORKDIR /app COPY --from=build /app/publish . +LABEL org.opencontainers.image.title="Budgeteer" \ + org.opencontainers.image.description="A personal finance dashboard built with Blazor Server (.NET 10). Multi-account support, recurring transactions, category management, balance trend charts, and ASP.NET Identity authentication." \ + org.opencontainers.image.url="https://github.com/edgeofsanity76/Budgeteer" \ + org.opencontainers.image.source="https://github.com/edgeofsanity76/Budgeteer" \ + org.opencontainers.image.licenses="MIT" + # Ensure the SQLite data directory exists and is writable RUN mkdir -p /app/data -ENV ConnectionStrings__DefaultConnection="Data Source=/app/data/budgetapp.db" ENTRYPOINT ["dotnet", "BudgetApp.dll"]