add attendance

This commit is contained in:
Chris Chen
2026-06-20 19:33:04 -07:00
parent 2af169fa60
commit 87425b3276
24 changed files with 1357 additions and 5 deletions
@@ -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;
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
[HttpGet("today")]
[AllowAnonymous]
public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet]
[Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to));
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
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; }
}
+12
View File
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -284,6 +285,17 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
});
// ── MealAttendance (one shared row per Sunday) ───────────────────────
builder.Entity<MealAttendance>(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<MonthlyStatement>(entity =>
{
+17
View File
@@ -0,0 +1,17 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// 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.
/// </summary>
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; }
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.SignalR;
using ROLAC.API.Services;
namespace ROLAC.API.Hubs;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -885,6 +885,56 @@ namespace ROLAC.API.Migrations
b.ToTable("GivingCategories");
});
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AdultCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateOnly>("AttendanceDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("KidCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("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<int>("Id")
+5
View File
@@ -133,6 +133,10 @@ builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// 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<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,27 @@
using ROLAC.API.DTOs.MealAttendance;
namespace ROLAC.API.Services;
public interface IMealAttendanceService
{
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
DateOnly Today { get; }
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
/// <summary>
/// Atomically adds <paramref name="delta"/> (may be negative) to one age-group column,
/// clamped at zero, and returns the resulting authoritative counts.
/// </summary>
Task<AttendanceCountsDto> IncrementAsync(DateOnly date, string category, int delta);
/// <summary>
/// Sets one age-group column to an absolute <paramref name="value"/> (clamped at zero),
/// overwriting the current count, and returns the resulting authoritative counts.
/// </summary>
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
}
@@ -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<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
{
await EnsureRowAsync(date);
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> 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<AttendanceCountsDto> 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<IReadOnlyList<AttendanceCountsDto>> 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<AttendanceCountsDto> 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,
};
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "rolac-app",
"runtimeExecutable": "npm",
"runtimeArgs": ["start"],
"port": 4200
}
]
}
+193 -1
View File
@@ -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",
+1
View File
@@ -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",
+4
View File
@@ -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',
@@ -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<Router>;
beforeEach(() => {
router = jasmine.createSpyObj<Router>('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' });
});
});
@@ -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']);
}
@@ -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';
@@ -0,0 +1,72 @@
<div class="ac">
<div class="ac__inner">
<!-- Header: church + auto-selected current day -->
<header class="ac__head">
<span class="ac__eyebrow">River of Life · 生命河靈糧堂</span>
<h1 class="ac__title">Sunday Worship Count<span>主日崇拜人數統計</span></h1>
<div class="ac__date">{{ today | date:'fullDate' }}</div>
<div class="ac__status" [class.is-on]="connected">
<span class="ac__dot"></span>
{{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }}
</div>
</header>
<!-- One row per age group, each with its own big +/- controls -->
<section class="ac__rows">
<article class="row" *ngFor="let row of rows">
<div class="row__label">
<span class="row__en">{{ row.labelEn }}</span>
<span class="row__zh">{{ row.labelZh }}</span>
</div>
<div class="row__controls">
<button type="button" class="btn btn--minus"
[disabled]="display(row.key) === 0"
(click)="bump(row.key, -1)"
aria-label="Decrease"></button>
<button type="button" class="row__num" [attr.data-key]="row.key"
(click)="openEditor(row)"
[attr.aria-label]="'Edit ' + row.labelEn + ' count'">
{{ display(row.key) }}
</button>
<button type="button" class="btn btn--plus"
(click)="bump(row.key, 1)"
aria-label="Increase">+</button>
</div>
</article>
</section>
<!-- Live grand total -->
<footer class="ac__total">
<span class="ac__total-label">Total · 總人數</span>
<span class="ac__total-num">
{{ display('adult') + display('youth') + display('kid') }}
</span>
</footer>
</div>
<!-- Direct-entry dialog: tap a number to type an exact count -->
<div class="edit" *ngIf="editing" (click)="cancelEditor()">
<div class="edit__card" (click)="$event.stopPropagation()">
<div class="edit__label">
<span class="edit__en">{{ editing.labelEn }}</span>
<span class="edit__zh">{{ editing.labelZh }}</span>
</div>
<input class="edit__input" type="number" inputmode="numeric" min="0" step="1"
[(ngModel)]="editValue" autofocus
(keyup.enter)="confirmEditor()" (keyup.escape)="cancelEditor()" />
<div class="edit__actions">
<button type="button" class="edit__btn edit__btn--cancel"
(click)="cancelEditor()">Cancel · 取消</button>
<button type="button" class="edit__btn edit__btn--confirm"
(click)="confirmEditor()">Update · 更新</button>
</div>
</div>
</div>
</div>
@@ -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;
}
@@ -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<AttendanceCategory, number> = { 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<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
// Taps accumulated but not yet sent to the server.
private pending: Record<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
// Deltas sent to the server but not yet acknowledged.
private inFlight: Record<AttendanceCategory, number> = { 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<AttendanceCategory, boolean> = { 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<AttendanceCategory, Subject<void>> = {
adult: new Subject<void>(),
youth: new Subject<void>(),
kid: new Subject<void>(),
};
private readonly destroy$ = new Subject<void>();
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];
}
}
}
@@ -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<AttendanceCounts | null>(null);
/** Latest authoritative counts pushed by the server (null until first message). */
get counts$(): Observable<AttendanceCounts | null> {
return this.counts$$.asObservable();
}
async start(): Promise<void> {
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<void> {
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<void> {
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<void> {
if (this.connection?.state !== HubConnectionState.Connected) {
throw new Error('Not connected to the attendance hub.');
}
await this.connection.invoke('SetCount', category, value);
}
}
@@ -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<AttendanceCounts> {
return this.http.get<AttendanceCounts>(`${this.endpoint}/today`);
}
/** Daily counts within an inclusive yyyy-MM-dd range, for the dashboard chart. */
getRange(from: string, to: string): Observable<AttendanceCounts[]> {
const params = new HttpParams().set('from', from).set('to', to);
return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
}
}
@@ -64,8 +64,56 @@
</div>
</div>
<!-- Recent Transactions -->
<!-- Sunday Attendance Trend -->
<div class="section">
<div class="section-header">
<h2>Sunday Attendance · 主日出席人數</h2>
</div>
<div class="attendance-chart">
<kendo-chart *ngIf="hasAttendanceData" [style.height.px]="340">
<kendo-chart-legend position="bottom"></kendo-chart-legend>
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="attendanceDates">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-series>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceKid" name="Kid · 兒童"
color="#42a5f5">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceYouth" name="Youth · 青少年"
color="#66bb6a">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceAdult" name="Adult · 成人"
color="#ef5350">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<!-- Invisible line series whose only job is to carry the total label on top of each bar. -->
<kendo-chart-series-item type="line" [data]="attendanceTotal" [visibleInLegend]="false"
color="transparent" [width]="0" [markers]="{ visible: false }"
[highlight]="{ visible: false }" [tooltip]="{ visible: false }">
<kendo-chart-series-item-labels [visible]="true" position="above" color="#374151"
font="bold 13px sans-serif" [content]="totalLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-tooltip [shared]="true" background="#1f2937" color="#ffffff">
</kendo-chart-tooltip>
</kendo-chart>
<p *ngIf="!hasAttendanceData" class="attendance-empty">
No attendance recorded yet · 尚無出席紀錄
</p>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
@@ -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;
}
@@ -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)}`;
}
}