From 87425b3276a3d1350e527e44a502abe20424cc64 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 20 Jun 2026 19:33:04 -0700 Subject: [PATCH] add attendance --- .../Controllers/MealAttendanceController.cs | 26 ++ .../MealAttendance/AttendanceCountsDto.cs | 10 + API/ROLAC.API/Data/AppDbContext.cs | 12 + API/ROLAC.API/Entities/MealAttendance.cs | 17 ++ API/ROLAC.API/Hubs/AttendanceHub.cs | 39 +++ .../Migrations/AppDbContextModelSnapshot.cs | 50 +++ API/ROLAC.API/Program.cs | 5 + .../Services/IMealAttendanceService.cs | 27 ++ .../Services/MealAttendanceService.cs | 125 ++++++++ APP/.claude/launch.json | 11 + APP/package-lock.json | 194 +++++++++++- APP/package.json | 1 + APP/src/app/app.routes.ts | 4 + .../interceptors/auth.interceptor.spec.ts | 63 ++++ .../app/core/interceptors/auth.interceptor.ts | 6 +- .../models/attendance.model.ts | 10 + .../attendance-counter-page.component.html | 72 +++++ .../attendance-counter-page.component.scss | 286 ++++++++++++++++++ .../attendance-counter-page.component.ts | 188 ++++++++++++ .../services/attendance-signalr.service.ts | 68 +++++ .../services/meal-attendance-api.service.ts | 25 ++ .../pages/dashboard/dashboard.component.html | 50 ++- .../pages/dashboard/dashboard.component.scss | 14 + .../pages/dashboard/dashboard.component.ts | 59 +++- 24 files changed, 1357 insertions(+), 5 deletions(-) create mode 100644 API/ROLAC.API/Controllers/MealAttendanceController.cs create mode 100644 API/ROLAC.API/DTOs/MealAttendance/AttendanceCountsDto.cs create mode 100644 API/ROLAC.API/Entities/MealAttendance.cs create mode 100644 API/ROLAC.API/Hubs/AttendanceHub.cs create mode 100644 API/ROLAC.API/Services/IMealAttendanceService.cs create mode 100644 API/ROLAC.API/Services/MealAttendanceService.cs create mode 100644 APP/.claude/launch.json create mode 100644 APP/src/app/core/interceptors/auth.interceptor.spec.ts create mode 100644 APP/src/app/features/meal-attendance/models/attendance.model.ts create mode 100644 APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.html create mode 100644 APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.scss create mode 100644 APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.ts create mode 100644 APP/src/app/features/meal-attendance/services/attendance-signalr.service.ts create mode 100644 APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts diff --git a/API/ROLAC.API/Controllers/MealAttendanceController.cs b/API/ROLAC.API/Controllers/MealAttendanceController.cs new file mode 100644 index 0000000..a89f689 --- /dev/null +++ b/API/ROLAC.API/Controllers/MealAttendanceController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/meal-attendance")] +public class MealAttendanceController : ControllerBase +{ + private readonly IMealAttendanceService _svc; + + public MealAttendanceController(IMealAttendanceService svc) => _svc = svc; + + /// Today's live counts. Public — feeds the volunteer counter page on first load. + [HttpGet("today")] + [AllowAnonymous] + public async Task GetToday() + => Ok(await _svc.GetOrCreateAsync(_svc.Today)); + + /// Daily counts within a date range, for the back-office dashboard chart. + [HttpGet] + [Authorize] + public async Task GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to) + => Ok(await _svc.GetRangeAsync(from, to)); +} diff --git a/API/ROLAC.API/DTOs/MealAttendance/AttendanceCountsDto.cs b/API/ROLAC.API/DTOs/MealAttendance/AttendanceCountsDto.cs new file mode 100644 index 0000000..122d111 --- /dev/null +++ b/API/ROLAC.API/DTOs/MealAttendance/AttendanceCountsDto.cs @@ -0,0 +1,10 @@ +namespace ROLAC.API.DTOs.MealAttendance; + +/// The current head-count for one Sunday, broadcast over SignalR. +public class AttendanceCountsDto +{ + public string Date { get; set; } = ""; // yyyy-MM-dd (local) + public int Adult { get; set; } + public int Youth { get; set; } + public int Kid { get; set; } +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index d448719..240dc75 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext public DbSet ChurchProfiles => Set(); public DbSet Checks => Set(); public DbSet CheckLines => Set(); + public DbSet MealAttendances => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -284,6 +285,17 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict); }); + // ── MealAttendance (one shared row per Sunday) ─────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.AdultCount).HasDefaultValue(0); + entity.Property(e => e.YouthCount).HasDefaultValue(0); + entity.Property(e => e.KidCount).HasDefaultValue(0); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.HasIndex(e => e.AttendanceDate).IsUnique(); + }); + // ── MonthlyStatement ───────────────────────────────────────────────── builder.Entity(entity => { diff --git a/API/ROLAC.API/Entities/MealAttendance.cs b/API/ROLAC.API/Entities/MealAttendance.cs new file mode 100644 index 0000000..265e3ee --- /dev/null +++ b/API/ROLAC.API/Entities/MealAttendance.cs @@ -0,0 +1,17 @@ +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +/// +/// One row per Sunday holding the live shared head-count for the three +/// age groups. Volunteers increment these concurrently from the public +/// counter page; the columns are updated with atomic SQL increments. +/// +public class MealAttendance : AuditableEntity +{ + public int Id { get; set; } + public DateOnly AttendanceDate { get; set; } + public int AdultCount { get; set; } + public int YouthCount { get; set; } + public int KidCount { get; set; } +} diff --git a/API/ROLAC.API/Hubs/AttendanceHub.cs b/API/ROLAC.API/Hubs/AttendanceHub.cs new file mode 100644 index 0000000..25f5f77 --- /dev/null +++ b/API/ROLAC.API/Hubs/AttendanceHub.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.SignalR; +using ROLAC.API.Services; + +namespace ROLAC.API.Hubs; + +/// +/// Real-time hub backing the public Sunday attendance counter. Anonymous +/// (no [Authorize]) so volunteers can use it without logging in. Every +/// increment is broadcast to all connected clients so multiple people can +/// count the same Sunday together and see each other's changes instantly. +/// +public class AttendanceHub : Hub +{ + private readonly IMealAttendanceService _svc; + + public AttendanceHub(IMealAttendanceService svc) => _svc = svc; + + // Push the current counts to a client the moment it connects. + public override async Task OnConnectedAsync() + { + var counts = await _svc.GetOrCreateAsync(_svc.Today); + await Clients.Caller.SendAsync("ReceiveCounts", counts); + await base.OnConnectedAsync(); + } + + // Apply a batched delta for one age group, then broadcast the new totals to everyone. + public async Task Increment(string category, int delta) + { + var counts = await _svc.IncrementAsync(_svc.Today, category, delta); + await Clients.All.SendAsync("ReceiveCounts", counts); + } + + // Overwrite one age group with an absolute value, then broadcast the new totals to everyone. + public async Task SetCount(string category, int value) + { + var counts = await _svc.SetAsync(_svc.Today, category, value); + await Clients.All.SendAsync("ReceiveCounts", counts); + } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 78a707c..780c766 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -885,6 +885,56 @@ namespace ROLAC.API.Migrations b.ToTable("GivingCategories"); }); + modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdultCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("AttendanceDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("KidCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("YouthCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AttendanceDate") + .IsUnique(); + + b.ToTable("MealAttendances"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Member", b => { b.Property("Id") diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 38c8ec0..1dd20b9 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -133,6 +133,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Real-time hub for the live Sunday attendance counter. +builder.Services.AddSignalR(); // --------------------------------------------------------------------------- // Swagger / MVC @@ -207,6 +211,7 @@ app.UseCors("Angular"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +app.MapHub("/hubs/attendance"); app.MapHealthChecks("/health"); app.Run(); diff --git a/API/ROLAC.API/Services/IMealAttendanceService.cs b/API/ROLAC.API/Services/IMealAttendanceService.cs new file mode 100644 index 0000000..b0284e8 --- /dev/null +++ b/API/ROLAC.API/Services/IMealAttendanceService.cs @@ -0,0 +1,27 @@ +using ROLAC.API.DTOs.MealAttendance; + +namespace ROLAC.API.Services; + +public interface IMealAttendanceService +{ + /// Today's date in the server's local time zone (the church's "current Sunday"). + DateOnly Today { get; } + + /// Returns the counts for , creating a zeroed row if none exists. + Task GetOrCreateAsync(DateOnly date); + + /// + /// Atomically adds (may be negative) to one age-group column, + /// clamped at zero, and returns the resulting authoritative counts. + /// + Task IncrementAsync(DateOnly date, string category, int delta); + + /// + /// Sets one age-group column to an absolute (clamped at zero), + /// overwriting the current count, and returns the resulting authoritative counts. + /// + Task SetAsync(DateOnly date, string category, int value); + + /// Returns the daily counts within the inclusive date range, ordered by date (for the dashboard). + Task> GetRangeAsync(DateOnly from, DateOnly to); +} diff --git a/API/ROLAC.API/Services/MealAttendanceService.cs b/API/ROLAC.API/Services/MealAttendanceService.cs new file mode 100644 index 0000000..343210d --- /dev/null +++ b/API/ROLAC.API/Services/MealAttendanceService.cs @@ -0,0 +1,125 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.MealAttendance; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class MealAttendanceService : IMealAttendanceService +{ + private readonly AppDbContext _db; + + public MealAttendanceService(AppDbContext db) => _db = db; + + // Server local time is assumed to match the church's local day. + public DateOnly Today => DateOnly.FromDateTime(DateTime.Now); + + public async Task GetOrCreateAsync(DateOnly date) + { + await EnsureRowAsync(date); + return await ReadAsync(date); + } + + public async Task IncrementAsync(DateOnly date, string category, int delta) + { + await EnsureRowAsync(date); + + var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date); + + // Atomic, race-free server-side increment; never let a column go below zero. + switch (category.Trim().ToLowerInvariant()) + { + case "adult": + await rows.ExecuteUpdateAsync(s => s.SetProperty( + a => a.AdultCount, a => a.AdultCount + delta < 0 ? 0 : a.AdultCount + delta)); + break; + case "youth": + await rows.ExecuteUpdateAsync(s => s.SetProperty( + a => a.YouthCount, a => a.YouthCount + delta < 0 ? 0 : a.YouthCount + delta)); + break; + case "kid": + await rows.ExecuteUpdateAsync(s => s.SetProperty( + a => a.KidCount, a => a.KidCount + delta < 0 ? 0 : a.KidCount + delta)); + break; + default: + throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category)); + } + + return await ReadAsync(date); + } + + public async Task SetAsync(DateOnly date, string category, int value) + { + await EnsureRowAsync(date); + + // Counts can never be negative; clamp before writing. + var clamped = value < 0 ? 0 : value; + + var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date); + + switch (category.Trim().ToLowerInvariant()) + { + case "adult": + await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.AdultCount, clamped)); + break; + case "youth": + await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.YouthCount, clamped)); + break; + case "kid": + await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.KidCount, clamped)); + break; + default: + throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category)); + } + + return await ReadAsync(date); + } + + public async Task> GetRangeAsync(DateOnly from, DateOnly to) + { + var rows = await _db.MealAttendances.AsNoTracking() + .Where(a => a.AttendanceDate >= from && a.AttendanceDate <= to) + .OrderBy(a => a.AttendanceDate) + .ToListAsync(); + + return rows.Select(ToDto).ToList(); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + // Insert a zeroed row if today's row doesn't exist yet. A concurrent insert + // that wins the unique index throws DbUpdateException — harmless, the row exists. + private async Task EnsureRowAsync(DateOnly date) + { + if (await _db.MealAttendances.AsNoTracking().AnyAsync(a => a.AttendanceDate == date)) + return; + + var row = new MealAttendance { AttendanceDate = date }; + _db.MealAttendances.Add(row); + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + _db.Entry(row).State = EntityState.Detached; + } + } + + private async Task ReadAsync(DateOnly date) + { + var row = await _db.MealAttendances.AsNoTracking() + .FirstOrDefaultAsync(a => a.AttendanceDate == date); + return row is null + ? new AttendanceCountsDto { Date = date.ToString("yyyy-MM-dd") } + : ToDto(row); + } + + private static AttendanceCountsDto ToDto(MealAttendance row) => new() + { + Date = row.AttendanceDate.ToString("yyyy-MM-dd"), + Adult = row.AdultCount, + Youth = row.YouthCount, + Kid = row.KidCount, + }; +} diff --git a/APP/.claude/launch.json b/APP/.claude/launch.json new file mode 100644 index 0000000..fdc2f21 --- /dev/null +++ b/APP/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "rolac-app", + "runtimeExecutable": "npm", + "runtimeArgs": ["start"], + "port": 4200 + } + ] +} diff --git a/APP/package-lock.json b/APP/package-lock.json index 8ff4754..0dd3cf1 100644 --- a/APP/package-lock.json +++ b/APP/package-lock.json @@ -16,6 +16,7 @@ "@angular/localize": "^20.1.6", "@angular/platform-browser": "^20.1.0", "@angular/router": "^20.1.0", + "@microsoft/signalr": "^8.0.17", "@progress/kendo-angular-buttons": "^20.0.0", "@progress/kendo-angular-charts": "^20.0.0", "@progress/kendo-angular-common": "^20.0.0", @@ -2082,6 +2083,49 @@ "win32" ] }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz", @@ -5334,6 +5378,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -6688,6 +6744,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -6840,6 +6905,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9254,6 +9329,26 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp": { "version": "11.4.2", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.4.2.tgz", @@ -10224,6 +10319,27 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/psl/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -10257,6 +10373,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -10328,7 +10450,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -10624,6 +10745,12 @@ "node": ">= 18" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -11393,6 +11520,45 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11587,6 +11753,16 @@ "node": ">=6" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -11747,6 +11923,22 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/APP/package.json b/APP/package.json index c8b485f..37f955a 100644 --- a/APP/package.json +++ b/APP/package.json @@ -30,6 +30,7 @@ "@angular/localize": "^20.1.6", "@angular/platform-browser": "^20.1.0", "@angular/router": "^20.1.0", + "@microsoft/signalr": "^8.0.17", "@progress/kendo-angular-buttons": "^20.0.0", "@progress/kendo-angular-charts": "^20.0.0", "@progress/kendo-angular-common": "^20.0.0", diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index a189b3c..860eb0d 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -17,11 +17,15 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component'; import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component'; import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; +import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; export const routes: Routes = [ // Public routes { path: 'login', component: LoginPage }, + // Public Sunday meal attendance counter — no login required (volunteers on phones). + { path: 'attendance', component: AttendanceCounterPageComponent }, + // Keep the startup surface intentionally small: login + guarded mock dashboard. { path: 'user-portal', diff --git a/APP/src/app/core/interceptors/auth.interceptor.spec.ts b/APP/src/app/core/interceptors/auth.interceptor.spec.ts new file mode 100644 index 0000000..2f2171a --- /dev/null +++ b/APP/src/app/core/interceptors/auth.interceptor.spec.ts @@ -0,0 +1,63 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { authInterceptor } from './auth.interceptor'; +import { AuthService } from '../../shared/services/auth.service'; +import { ApiConfigService } from '../services/api-config.service'; + +describe('authInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let apiConfig: ApiConfigService; + let router: jasmine.SpyObj; + + beforeEach(() => { + router = jasmine.createSpyObj('Router', ['navigate']); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + AuthService, + ApiConfigService, + { provide: Router, useValue: router }, + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + apiConfig = TestBed.inject(ApiConfigService); + }); + + afterEach(() => httpMock.verify()); + + it('does NOT redirect to login when a public auth request (refresh) returns 401', () => { + // Startup session-restore: an unauthenticated visitor has no refresh cookie, so + // POST /auth/refresh returns 401. This must NOT bounce them to /login — public + // routes like /attendance need to stay put. refresh() handles its own 401. + http.post(`${apiConfig.authUrl}/refresh`, {}).subscribe({ error: () => {} }); + httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush( + { message: 'No cookie' }, + { status: 401, statusText: 'Unauthorized' }, + ); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('redirects to login when a protected request returns 401 after refresh fails', () => { + http.get(`${apiConfig.getBaseUrl()}/expenses`).subscribe({ error: () => {} }); + + // Original protected call 401s -> interceptor attempts a silent refresh... + httpMock.expectOne(`${apiConfig.getBaseUrl()}/expenses`).flush( + null, { status: 401, statusText: 'Unauthorized' }, + ); + // ...refresh also 401s (no valid cookie) -> genuine auth failure. + httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush( + null, { status: 401, statusText: 'Unauthorized' }, + ); + + expect(router.navigate).toHaveBeenCalledWith(['/login']); + + // logout() fires a fire-and-forget POST /auth/logout; flush it so verify() stays clean. + httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' }); + }); +}); diff --git a/APP/src/app/core/interceptors/auth.interceptor.ts b/APP/src/app/core/interceptors/auth.interceptor.ts index 5f1ee3a..a17e559 100644 --- a/APP/src/app/core/interceptors/auth.interceptor.ts +++ b/APP/src/app/core/interceptors/auth.interceptor.ts @@ -39,7 +39,11 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { ); } - if (error.status === 401) { + // Redirect on a genuine auth failure, but NOT for the public auth endpoints + // (login / refresh / logout). The startup session-restore POSTs /auth/refresh on + // every page load; for an unauthenticated visitor that 401s, and bouncing them to + // /login here would break public routes like /attendance. refresh() handles its own 401. + if (error.status === 401 && !isPublicAuthRequest(req, apiConfig)) { authService.logout(); router.navigate(['/login']); } diff --git a/APP/src/app/features/meal-attendance/models/attendance.model.ts b/APP/src/app/features/meal-attendance/models/attendance.model.ts new file mode 100644 index 0000000..4e1178d --- /dev/null +++ b/APP/src/app/features/meal-attendance/models/attendance.model.ts @@ -0,0 +1,10 @@ +/** The three age-group head-counts for one Sunday. */ +export interface AttendanceCounts { + date: string; // yyyy-MM-dd + adult: number; + youth: number; + kid: number; +} + +/** The age-group keys the server's Increment hub method accepts. */ +export type AttendanceCategory = 'adult' | 'youth' | 'kid'; diff --git a/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.html b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.html new file mode 100644 index 0000000..6179fc5 --- /dev/null +++ b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.html @@ -0,0 +1,72 @@ +
+
+ + +
+ River of Life · 生命河靈糧堂 +

Sunday Worship Count主日崇拜人數統計

+
{{ today | date:'fullDate' }}
+
+ + {{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }} +
+
+ + +
+
+
+ {{ row.labelEn }} + {{ row.labelZh }} +
+ +
+ + + + + +
+
+
+ + +
+ Total · 總人數 + + {{ display('adult') + display('youth') + display('kid') }} + +
+ +
+ + +
+
+
+ {{ editing.labelEn }} + {{ editing.labelZh }} +
+ + + +
+ + +
+
+
+
diff --git a/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.scss b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.scss new file mode 100644 index 0000000..bdac2ab --- /dev/null +++ b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.scss @@ -0,0 +1,286 @@ +:host { + display: block; +} + +.ac { + min-height: 100vh; + min-height: 100dvh; + background: radial-gradient(120% 80% at 50% 0%, #1e293b 0%, #0f172a 55%, #020617 100%); + color: #e2e8f0; + padding: clamp(1rem, 4vw, 2rem) 1rem calc(1.5rem + env(safe-area-inset-bottom)); + display: flex; + justify-content: center; +} + +.ac__inner { + width: 100%; + max-width: 30rem; + display: flex; + flex-direction: column; + gap: clamp(1rem, 3.5vw, 1.75rem); +} + +/* ── Header ────────────────────────────────────────────────────────────── */ +.ac__head { + text-align: center; +} + +.ac__eyebrow { + display: block; + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #94a3b8; +} + +.ac__title { + margin: 0.35rem 0 0; + font-size: clamp(1.5rem, 6vw, 1.9rem); + font-weight: 700; + line-height: 1.15; + color: #f8fafc; + + span { + display: block; + font-size: 0.9rem; + font-weight: 500; + color: #cbd5e1; + margin-top: 0.2rem; + } +} + +.ac__date { + margin-top: 0.75rem; + font-size: 1.05rem; + font-weight: 600; + color: #38bdf8; +} + +.ac__status { + margin-top: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + color: #94a3b8; + + &.is-on { color: #4ade80; } +} + +.ac__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #64748b; + + .is-on & { + background: #4ade80; + box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.18); + } +} + +/* ── Counter rows ──────────────────────────────────────────────────────── */ +.ac__rows { + display: flex; + flex-direction: column; + gap: clamp(0.85rem, 3vw, 1.25rem); +} + +.row { + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 1.25rem; + padding: 1rem 1.1rem 1.25rem; + backdrop-filter: blur(6px); +} + +.row__label { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.85rem; +} + +.row__en { + font-size: 1.15rem; + font-weight: 700; + color: #f1f5f9; +} + +.row__zh { + font-size: 0.95rem; + color: #94a3b8; +} + +.row__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* Big, thumb-friendly tap targets */ +.btn { + flex: 0 0 auto; + width: clamp(4rem, 18vw, 5rem); + height: clamp(4rem, 18vw, 5rem); + border-radius: 50%; + border: none; + font-size: clamp(2rem, 9vw, 2.6rem); + font-weight: 700; + line-height: 1; + color: #fff; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: transform 0.08s ease, filter 0.15s ease, opacity 0.15s ease; + box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.6); + + &:active { transform: scale(0.92); } + &:disabled { opacity: 0.35; cursor: default; } +} + +.btn--minus { + background: linear-gradient(135deg, #475569, #334155); +} + +.btn--plus { + background: linear-gradient(135deg, #2563eb, #4f46e5); +} + +/* Tap the number to type an exact count */ +.row__num { + flex: 1 1 auto; + text-align: center; + font-size: clamp(2.6rem, 13vw, 3.6rem); + font-weight: 800; + line-height: 1; + padding: 0.4rem 0; + border: none; + background-color: transparent; + border-radius: 0.85rem; + color: #fff; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: transform 0.08s ease, filter 0.15s ease; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + + &:active { transform: scale(0.94); } + + &[data-key='adult'] { background-image: linear-gradient(135deg, #38bdf8, #6366f1); } + &[data-key='youth'] { background-image: linear-gradient(135deg, #34d399, #14b8a6); } + &[data-key='kid'] { background-image: linear-gradient(135deg, #fbbf24, #fb923c); } +} + +/* ── Direct-entry dialog ───────────────────────────────────────────────── */ +.edit { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.72); + backdrop-filter: blur(4px); +} + +.edit__card { + width: 100%; + max-width: 22rem; + display: flex; + flex-direction: column; + gap: 1.1rem; + padding: 1.5rem; + border-radius: 1.25rem; + background: linear-gradient(135deg, #1e293b, #0f172a); + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.8); +} + +.edit__label { + display: flex; + align-items: baseline; + gap: 0.6rem; +} + +.edit__en { + font-size: 1.2rem; + font-weight: 700; + color: #f1f5f9; +} + +.edit__zh { + font-size: 0.95rem; + color: #94a3b8; +} + +.edit__input { + width: 100%; + text-align: center; + font-size: clamp(2.4rem, 12vw, 3rem); + font-weight: 800; + line-height: 1; + padding: 0.6rem 0.5rem; + border-radius: 0.85rem; + color: #f8fafc; + background: rgba(15, 23, 42, 0.8); + border: 2px solid rgba(99, 102, 241, 0.5); + outline: none; + + &:focus { border-color: #6366f1; } +} + +.edit__actions { + display: flex; + gap: 0.75rem; +} + +.edit__btn { + flex: 1 1 0; + padding: 0.85rem 0.5rem; + border: none; + border-radius: 0.85rem; + font-size: 1rem; + font-weight: 700; + color: #fff; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform 0.08s ease, filter 0.15s ease; + + &:active { transform: scale(0.96); } +} + +.edit__btn--cancel { + background: linear-gradient(135deg, #475569, #334155); +} + +.edit__btn--confirm { + background: linear-gradient(135deg, #2563eb, #4f46e5); +} + +/* ── Grand total ───────────────────────────────────────────────────────── */ +.ac__total { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.4rem; + border-radius: 1.25rem; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(79, 70, 229, 0.18)); + border: 1px solid rgba(99, 102, 241, 0.35); +} + +.ac__total-label { + font-size: 1.05rem; + font-weight: 600; + color: #c7d2fe; +} + +.ac__total-num { + font-size: 2.4rem; + font-weight: 800; + color: #fff; +} diff --git a/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.ts b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.ts new file mode 100644 index 0000000..b0fcb1a --- /dev/null +++ b/APP/src/app/features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component.ts @@ -0,0 +1,188 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject, debounceTime, takeUntil } from 'rxjs'; +import { AttendanceSignalrService } from '../../services/attendance-signalr.service'; +import { AttendanceCategory } from '../../models/attendance.model'; + +interface CounterRow { + key: AttendanceCategory; + labelEn: string; + labelZh: string; +} + +@Component({ + selector: 'app-attendance-counter-page', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './attendance-counter-page.component.html', + styleUrls: ['./attendance-counter-page.component.scss'], +}) +export class AttendanceCounterPageComponent implements OnInit, OnDestroy { + /** Auto-selected current day shown in the header. */ + readonly today = new Date(); + + readonly rows: CounterRow[] = [ + { key: 'adult', labelEn: 'Adult', labelZh: '成人' }, + { key: 'youth', labelEn: 'Youth', labelZh: '青少年' }, + { key: 'kid', labelEn: 'Kid', labelZh: '兒童' }, + ]; + + /** The number shown on screen — updated optimistically on every tap. */ + local: Record = { adult: 0, youth: 0, kid: 0 }; + + connected = false; + + // The row whose number is being directly edited (null when the dialog is closed). + editing: CounterRow | null = null; + // Bound to the dialog's input box while editing. + editValue: number | null = null; + + // Last authoritative value from the server (per broadcast). + private server: Record = { adult: 0, youth: 0, kid: 0 }; + // Taps accumulated but not yet sent to the server. + private pending: Record = { adult: 0, youth: 0, kid: 0 }; + // Deltas sent to the server but not yet acknowledged. + private inFlight: Record = { adult: 0, youth: 0, kid: 0 }; + // True while an absolute set is awaiting its server ack, so reconcile() doesn't + // momentarily snap the number back to a stale broadcast. + private setting: Record = { adult: false, youth: false, kid: false }; + + // One debounce stream per category so taps on different rows don't cancel each other. + private readonly flush$: Record> = { + adult: new Subject(), + youth: new Subject(), + kid: new Subject(), + }; + private readonly destroy$ = new Subject(); + + constructor(private signalr: AttendanceSignalrService) {} + + ngOnInit(): void { + for (const row of this.rows) { + this.flush$[row.key] + .pipe(debounceTime(500), takeUntil(this.destroy$)) + .subscribe(() => this.flush(row.key)); + } + + this.signalr.counts$ + .pipe(takeUntil(this.destroy$)) + .subscribe(counts => { + if (!counts) { + return; + } + for (const row of this.rows) { + this.server[row.key] = counts[row.key]; + this.reconcile(row.key); + } + }); + + this.signalr.start() + .then(() => (this.connected = true)) + .catch(() => (this.connected = false)); + } + + ngOnDestroy(): void { + // Flush any taps the volunteer made just before leaving so nothing is lost. + for (const row of this.rows) { + this.flush(row.key); + } + this.destroy$.next(); + this.destroy$.complete(); + this.signalr.stop(); + } + + /** Displayed value for one age group. */ + display(key: AttendanceCategory): number { + return this.local[key]; + } + + /** Optimistic +/-: update the screen instantly, debounce the write to the DB. */ + bump(key: AttendanceCategory, step: number): void { + if (this.local[key] + step < 0) { + return; // never count below zero + } + this.local[key] += step; + this.pending[key] += step; + this.flush$[key].next(); + } + + /** Open the direct-entry dialog for a row, pre-filled with the current count. */ + openEditor(row: CounterRow): void { + this.editing = row; + this.editValue = this.local[row.key]; + } + + /** Close the dialog without changing anything. */ + cancelEditor(): void { + this.editing = null; + this.editValue = null; + } + + /** Apply the typed number, overwriting the current count for the edited row. */ + confirmEditor(): void { + if (!this.editing) { + return; + } + const row = this.editing; + const value = Math.floor(Number(this.editValue)); + // Ignore blank/invalid/negative input — just keep the current value. + if (!Number.isFinite(value) || value < 0) { + this.cancelEditor(); + return; + } + this.cancelEditor(); + this.setCount(row.key, value); + } + + // Overwrite one age group with an absolute value: show it instantly, discard any + // queued taps it supersedes, and push the new value to the server for everyone. + private setCount(key: AttendanceCategory, value: number): void { + if (value === this.local[key]) { + return; // nothing changed + } + this.pending[key] = 0; // taps before the set no longer matter + this.local[key] = value; + this.setting[key] = true; + + this.signalr.setCount(key, value) + .then(() => { + this.setting[key] = false; + this.reconcile(key); + }) + .catch(() => { + // Send failed — drop the guard and let the next broadcast correct us. + this.setting[key] = false; + this.reconcile(key); + }); + } + + // Send the accumulated delta for one category as a single batched write. + private flush(key: AttendanceCategory): void { + const delta = this.pending[key]; + if (delta === 0) { + return; + } + this.pending[key] = 0; + this.inFlight[key] += delta; + + this.signalr.increment(key, delta) + .then(() => { + this.inFlight[key] -= delta; + this.reconcile(key); + }) + .catch(() => { + // Send failed — re-queue the delta so the count isn't silently dropped. + this.inFlight[key] -= delta; + this.pending[key] += delta; + }); + } + + // Adopt the server's value only when nothing of ours is outstanding, so the + // local number never momentarily jumps back to a stale total mid-flight. + private reconcile(key: AttendanceCategory): void { + if (this.pending[key] === 0 && this.inFlight[key] === 0 && !this.setting[key]) { + this.local[key] = this.server[key]; + } + } +} diff --git a/APP/src/app/features/meal-attendance/services/attendance-signalr.service.ts b/APP/src/app/features/meal-attendance/services/attendance-signalr.service.ts new file mode 100644 index 0000000..c13a0f2 --- /dev/null +++ b/APP/src/app/features/meal-attendance/services/attendance-signalr.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { + HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel, +} from '@microsoft/signalr'; +import { environment } from '../../../../environments/environment'; +import { AttendanceCounts, AttendanceCategory } from '../models/attendance.model'; + +/** + * Thin wrapper around the AttendanceHub SignalR connection. Exposes the latest + * authoritative counts as an observable and a fire-and-forget increment() that + * sends a batched delta to the server. The server broadcasts the new totals + * back to every connected client. + */ +@Injectable({ providedIn: 'root' }) +export class AttendanceSignalrService { + // Hub lives at the host root; environment.apiUrl is e.g. http://localhost:42019/api + private readonly hubUrl = environment.apiUrl.replace(/\/api\/?$/, '') + '/hubs/attendance'; + + private connection?: HubConnection; + private readonly counts$$ = new BehaviorSubject(null); + + /** Latest authoritative counts pushed by the server (null until first message). */ + get counts$(): Observable { + return this.counts$$.asObservable(); + } + + async start(): Promise { + if (this.connection) { + return; + } + + this.connection = new HubConnectionBuilder() + .withUrl(this.hubUrl) + .withAutomaticReconnect() + .configureLogging(LogLevel.Warning) + .build(); + + this.connection.on('ReceiveCounts', (counts: AttendanceCounts) => { + this.counts$$.next(counts); + }); + + await this.connection.start(); + } + + async stop(): Promise { + if (this.connection) { + await this.connection.stop(); + this.connection = undefined; + } + } + + /** Send a batched delta (may be negative) for one age group. */ + async increment(category: AttendanceCategory, delta: number): Promise { + if (delta === 0 || this.connection?.state !== HubConnectionState.Connected) { + return; + } + await this.connection.invoke('Increment', category, delta); + } + + /** Overwrite one age group with an absolute value (clamped at zero server-side). */ + async setCount(category: AttendanceCategory, value: number): Promise { + if (this.connection?.state !== HubConnectionState.Connected) { + throw new Error('Not connected to the attendance hub.'); + } + await this.connection.invoke('SetCount', category, value); + } +} diff --git a/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts b/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts new file mode 100644 index 0000000..2bf97d0 --- /dev/null +++ b/APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { AttendanceCounts } from '../models/attendance.model'; + +@Injectable({ providedIn: 'root' }) +export class MealAttendanceApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('meal-attendance'); + } + + /** Today's live counts (public; SignalR-independent fallback). */ + getToday(): Observable { + return this.http.get(`${this.endpoint}/today`); + } + + /** Daily counts within an inclusive yyyy-MM-dd range, for the dashboard chart. */ + getRange(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get(this.endpoint, { params }); + } +} diff --git a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.html b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.html index 98022d8..cd6e99c 100644 --- a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.html +++ b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.html @@ -64,8 +64,56 @@ - + +
+
+

Sunday Attendance · 主日出席人數

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ No attendance recorded yet · 尚無出席紀錄 +

+
+
diff --git a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.scss b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.scss index eb7af1d..12850cc 100644 --- a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.scss +++ b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.scss @@ -534,3 +534,17 @@ } } } + +.attendance-chart { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 1rem; +} + +.attendance-empty { + text-align: center; + color: #94a3b8; + padding: 2rem 0; + margin: 0; +} diff --git a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.ts b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.ts index d5d27d8..39b4138 100644 --- a/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.ts +++ b/APP/src/app/portals/user-portal/pages/dashboard/dashboard.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { ChartsModule } from '@progress/kendo-angular-charts'; import { AuthService, UserInfo } from '../../../../shared/services/auth.service'; +import { MealAttendanceApiService } from '../../../../features/meal-attendance/services/meal-attendance-api.service'; interface Transaction { id: string; @@ -15,13 +17,21 @@ interface Transaction { @Component({ selector: 'app-dashboard', standalone: true, - imports: [CommonModule], + imports: [CommonModule, ChartsModule], templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'] }) export class DashboardComponent implements OnInit { currentUser: UserInfo | null = null; + // Sunday attendance trend (last 8 weeks) for the chart. + attendanceDates: string[] = []; + attendanceAdult: number[] = []; + attendanceYouth: number[] = []; + attendanceKid: number[] = []; + attendanceTotal: number[] = []; + hasAttendanceData = false; + activeTransactions = 5; pendingTasks = 12; completedTransactions = 23; @@ -57,15 +67,60 @@ export class DashboardComponent implements OnInit { } ]; - constructor(private authService: AuthService) { } + constructor( + private authService: AuthService, + private attendanceApi: MealAttendanceApiService, + ) { } ngOnInit(): void { this.authService.currentUser$.subscribe(user => { this.currentUser = user; }); + this.loadAttendance(); } getDisplayName(): string { return this.currentUser?.email || ''; } + + // Per-segment label: show the count inside its colored block, but hide zeros to avoid clutter. + segmentLabelContent = (args: { value: number }): string => { + return args.value > 0 ? String(args.value) : ''; + }; + + // Total label sitting on top of each stacked bar. + totalLabelContent = (args: { value: number }): string => { + return args.value > 0 ? String(args.value) : ''; + }; + + private loadAttendance(): void { + const today = new Date(); + const from = new Date(); + from.setDate(today.getDate() - 56); // last ~8 weeks + + this.attendanceApi.getRange(this.toLocalIso(from), this.toLocalIso(today)).subscribe({ + next: rows => { + this.attendanceDates = rows.map(r => this.toShortLabel(r.date)); + this.attendanceAdult = rows.map(r => r.adult); + this.attendanceYouth = rows.map(r => r.youth); + this.attendanceKid = rows.map(r => r.kid); + this.attendanceTotal = rows.map(r => r.adult + r.youth + r.kid); + this.hasAttendanceData = rows.length > 0; + }, + error: () => { this.hasAttendanceData = false; }, + }); + } + + // Local yyyy-MM-dd (never toISOString — it shifts the day by timezone). + private toLocalIso(d: Date): string { + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${month}-${day}`; + } + + // 'yyyy-MM-dd' → 'M/d' for the axis label (split to avoid timezone parsing). + private toShortLabel(isoDate: string): string { + const [, month, day] = isoDate.split('-'); + return `${Number(month)}/${Number(day)}`; + } }