Commit: b30f464
Parent: 7db02e2

Add **the date**

Mårten Åsberg committed on 2026-05-04 at 19:56
MatDenDagen/Components/Pages/Admin/Date.razor +148 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Date.razor b/MatDenDagen/Components/Pages/Admin/Date.razor
new file mode 100644
index 0000000..03adce0
@@ -0,0 +1,148 @@
@page "/admin/date"
@attribute [Authorize(Roles = "Admin")]
@using MatDenDagen.Infrastructure.Storage.Database
@using MatDenDagen.Models
@using MatDenDagen.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
@inject DateService dateService
@inject QuestionnaireContext questionnaireContext
<h2>Konfigurera datum</h2>
<EditForm FormName="ConfigureDateSpan" Model="@dateSpanModel" OnSubmit="@SaveDateSpan" Enhance>
<p>
<label>
<span>Startdatum:</span>
<input type="date" name="dateSpanModel.Start" value="@(dateSpanModel?.Start ?? string.Empty)" required />
</label>
</p>
<p>
<label>
<span>Slutdatum:</span>
<input type="date" name="dateSpanModel.End" value="@(dateSpanModel?.End ?? string.Empty)" required />
</label>
</p>
<p>
<input type="submit" value="Spara datumspan" />
</p>
</EditForm>
@if (errorMessage != null)
{
<p>@errorMessage</p>
}
@if (showSuccessMessage)
{
<p>Datumspanen har sparats!</p>
}
<hr />
@if (currentDateSpan is null)
{
<p>Ingen datumspan är konfigurerad ännu.</p>
}
else
{
<EditForm FormName="RandomizeDate" Model="@dateModel" OnSubmit="@RandomizeDate" Enhance>
<button type="submit" disabled="@(!canRandomizeDate)">
Slumpa datum för <strong>dagen</strong>
</button>
</EditForm>
@if (showRandomizeSuccessMessage)
{
<p>Datumet för <strong>dagen</strong> har slumpats och sparats!</p>
}
}
@code {
[SupplyParameterFromForm]
private DateSpanModel? dateSpanModel { get; set; }
[SupplyParameterFromForm]
private object? dateModel { get; set; }
private DateSpan? currentDateSpan { get; set; }
private bool showSuccessMessage { get; set; }
private bool showRandomizeSuccessMessage { get; set; }
private string? errorMessage { get; set; }
protected override async Task OnInitializedAsync()
{
currentDateSpan = await dateService.GetDateSpanAsync();
if (currentDateSpan != null)
{
dateSpanModel = new()
{
Start = currentDateSpan.Start.ToString(),
End = currentDateSpan.End.ToString()
};
}
else
{
dateSpanModel ??= new();
}
dateModel ??= new();
}
private async Task SaveDateSpan()
{
if (dateSpanModel?.Start == null || dateSpanModel?.End == null)
{
errorMessage = "Både startdatum och slutdatum måste anges.";
return;
}
if (!DateOnly.TryParse(dateSpanModel.Start, out var startDate) ||
!DateOnly.TryParse(dateSpanModel.End, out var endDate))
{
errorMessage = "Ogiltigt datumformat. Använd ÅÅÅÅ-MM-DD.";
return;
}
var dateSpan = new DateSpan { Start = startDate, End = endDate };
if (!dateSpan.IsValid)
{
errorMessage = "Startdatum måste vara före eller lika med slutdatum.";
return;
}
try
{
await dateService.SaveDateSpanAsync(dateSpan);
currentDateSpan = dateSpan;
showSuccessMessage = true;
}
catch (Exception ex)
{
errorMessage = "Ett fel uppstod vid sparande: " + ex.Message;
}
}
private async Task RandomizeDate()
{
var result = await dateService.RandomizeDateAsync();
if (result != null)
{
showRandomizeSuccessMessage = true;
}
else
{
errorMessage = "Kunde inte slumpa datum. Kontrollera att datumspanen är korrekt konfigurerad.";
}
}
private bool canRandomizeDate => currentDateSpan?.IsValid ?? false;
private sealed class DateSpanModel
{
public string? Start { get; set; }
public string? End { get; set; }
}
}
MatDenDagen/Components/Pages/Home.razor +37 -1
diff --git a/MatDenDagen/Components/Pages/Home.razor b/MatDenDagen/Components/Pages/Home.razor
index fd86623..f0569d3 100644
@@ -1,3 +1,39 @@
@page "/"
@using MatDenDagen.Models
@using MatDenDagen.Services
@inject DateService dateService
<p>Hem</p>
<h1>Välkommen till Mat den <strong>dagen</strong></h1>
@if (dateSpan is null)
{
<p>Ingen datumspan är konfigurerad ännu. Kontakta administratören.</p>
}
else
{
<h2>Datumspan</h2>
<p><strong>Dagen</strong> kommer att inträffa någon gång mellan:</p>
<p>
<code>@dateSpan.Start</code> och <code>@dateSpan.End</code>
</p>
@if (actualDate is null)
{
<p>Datumet för "the day" har inte slumpats ännu.</p>
}
else
{
<h2><strong>Dagen</strong> är den <code>@actualDate</code></h2>
}
}
@code {
private DateSpan? dateSpan { get; set; }
private DateOnly? actualDate { get; set; }
protected override async Task OnInitializedAsync()
{
dateSpan = await dateService.GetDateSpanAsync();
actualDate = (await dateService.GetDateConfigAsync())?.TheDay;
}
}
MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.Designer.cs +172 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.Designer.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.Designer.cs
new file mode 100644
index 0000000..f554649
@@ -0,0 +1,172 @@
// <auto-generated />
using System;
using MatDenDagen.Infrastructure.Storage.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
{
[DbContext(typeof(QuestionnaireContext))]
[Migration("20260504170948_CreateConfigTable")]
partial class CreateConfigTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("MatDenDagen.Models.Answer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("QuestionId")
.HasColumnType("TEXT");
b.Property<Guid?>("SubmissionId")
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("QuestionId");
b.HasIndex("SubmissionId");
b.ToTable("Answer");
});
modelBuilder.Entity("MatDenDagen.Models.Config", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Configs");
});
modelBuilder.Entity("MatDenDagen.Models.Participant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PhoneNumber")
.IsUnique();
b.ToTable("Participants");
});
modelBuilder.Entity("MatDenDagen.Models.Question", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Questions");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("Participant")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Participant");
b.ToTable("Submissions");
});
modelBuilder.Entity("MatDenDagen.Models.Upload", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid?>("SubmissionId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SubmissionId");
b.ToTable("Upload");
});
modelBuilder.Entity("MatDenDagen.Models.Answer", b =>
{
b.HasOne("MatDenDagen.Models.Question", null)
.WithMany()
.HasForeignKey("QuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MatDenDagen.Models.Submission", null)
.WithMany("Answers")
.HasForeignKey("SubmissionId");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.HasOne("MatDenDagen.Models.Participant", null)
.WithMany()
.HasForeignKey("Participant")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MatDenDagen.Models.Upload", b =>
{
b.HasOne("MatDenDagen.Models.Submission", null)
.WithMany("Uploads")
.HasForeignKey("SubmissionId");
});
modelBuilder.Entity("MatDenDagen.Models.Submission", b =>
{
b.Navigation("Answers");
b.Navigation("Uploads");
});
#pragma warning restore 612, 618
}
}
}
MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.cs +33 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/20260504170948_CreateConfigTable.cs
new file mode 100644
index 0000000..a402667
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
{
/// <inheritdoc />
public partial class CreateConfigTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Configs",
columns: table => new
{
Key = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_Configs", x => x.Key);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Configs");
}
}
}
MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs +14 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs b/MatDenDagen/Infrastructure/Storage/Database/Migrations/QuestionnaireContextModelSnapshot.cs
index 5660200..adc9f71 100644
@@ -42,6 +42,20 @@ namespace MatDenDagen.Infrastructure.Storage.Database.Migrations
b.ToTable("Answer");
});
modelBuilder.Entity("MatDenDagen.Models.Config", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Configs");
});
modelBuilder.Entity("MatDenDagen.Models.Participant", b =>
{
b.Property<Guid>("Id")
MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs +5 -0
diff --git a/MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs b/MatDenDagen/Infrastructure/Storage/Database/QuestionnaireContext.cs
index 792b702..249343a 100644
@@ -8,6 +8,7 @@ public sealed class QuestionnaireContext(DbContextOptions<QuestionnaireContext>
public required DbSet<Question> Questions { get; init; }
public required DbSet<Submission> Submissions { get; init; }
public required DbSet<Participant> Participants { get; init; }
public required DbSet<Config> Configs { get; init; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -34,5 +35,9 @@ public sealed class QuestionnaireContext(DbContextOptions<QuestionnaireContext>
participantBuilder.HasKey(p => p.Id);
participantBuilder.Property(p => p.Name);
participantBuilder.HasIndex(p => p.PhoneNumber).IsUnique();
var configBuilder = modelBuilder.Entity<Config>();
configBuilder.HasKey(c => c.Key);
configBuilder.Property(c => c.Value);
}
}
MatDenDagen/Models/Config.cs +8 -0
diff --git a/MatDenDagen/Models/Config.cs b/MatDenDagen/Models/Config.cs
new file mode 100644
index 0000000..cdbf036
@@ -0,0 +1,8 @@
namespace MatDenDagen.Models;
public sealed class Config
{
public required string Key { get; init; }
public required string Value { get; set; }
}
MatDenDagen/Models/DateConfig.cs +8 -0
diff --git a/MatDenDagen/Models/DateConfig.cs b/MatDenDagen/Models/DateConfig.cs
new file mode 100644
index 0000000..3a5d27b
@@ -0,0 +1,8 @@
using System;
namespace MatDenDagen.Models;
public sealed class DateConfig
{
public required DateOnly TheDay { get; init; }
}
MatDenDagen/Models/DateSpan.cs +23 -0
diff --git a/MatDenDagen/Models/DateSpan.cs b/MatDenDagen/Models/DateSpan.cs
new file mode 100644
index 0000000..2405fe2
@@ -0,0 +1,23 @@
using System;
namespace MatDenDagen.Models;
public sealed class DateSpan
{
public required DateOnly Start { get; init; }
public required DateOnly End { get; init; }
public bool IsValid => Start <= End;
public int DaysBetween => End.DayNumber - Start.DayNumber + 1;
public DateOnly GetRandomDate(Random random)
{
if (!IsValid || DaysBetween <= 0)
{
throw new InvalidOperationException("Invalid date span");
}
return Start.AddDays(random.Next(DaysBetween));
}
}
MatDenDagen/Program.cs +1 -2
diff --git a/MatDenDagen/Program.cs b/MatDenDagen/Program.cs
index 026c32d..0c31dba 100644
@@ -10,7 +10,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.Services.AddStorageServices();
builder.Services.AddStorageServices().AddAdminService().AddDateService();
builder.Services.AddRazorComponents();
@@ -24,7 +24,6 @@ builder
options.Cookie.Name = "AdminAuthCookie";
});
builder.Services.AddAuthorization();
builder.Services.AddAdminService();
var app = builder.Build();
MatDenDagen/Services/DateService.cs +129 -0
diff --git a/MatDenDagen/Services/DateService.cs b/MatDenDagen/Services/DateService.cs
new file mode 100644
index 0000000..b1484f7
@@ -0,0 +1,129 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using MatDenDagen.Infrastructure.Storage.Database;
using MatDenDagen.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace MatDenDagen.Services;
public sealed class DateService(ILogger<DateService> logger, Random random, QuestionnaireContext questionnaireContext)
{
private const string DateSpanConfigKey = "DateSpan";
private const string DateConfigKey = "DateConfig";
public async Task<DateSpan?> GetDateSpanAsync()
{
var config = await questionnaireContext.Configs.FirstOrDefaultAsync(c => c.Key == DateSpanConfigKey);
if (config?.Value == null)
{
return null;
}
try
{
return JsonSerializer.Deserialize<DateSpan>(config.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to deserialize DateSpan from database");
return null;
}
}
public async Task SaveDateSpanAsync(DateSpan dateSpan)
{
if (!dateSpan.IsValid)
{
throw new ArgumentException("Invalid date span: start date must be before or equal to end date");
}
var config = await questionnaireContext.Configs.FirstOrDefaultAsync(c => c.Key == DateSpanConfigKey);
var jsonValue = JsonSerializer.Serialize(dateSpan, ConfigJsonSerializerContext.Default.DateSpan);
if (config == null)
{
questionnaireContext.Configs.Add(new Config { Key = DateSpanConfigKey, Value = jsonValue });
}
else
{
config.Value = jsonValue;
questionnaireContext.Configs.Update(config);
}
await questionnaireContext.SaveChangesAsync();
}
public async Task<DateConfig?> GetDateConfigAsync()
{
var config = await questionnaireContext.Configs.FirstOrDefaultAsync(c => c.Key == DateConfigKey);
if (config?.Value == null)
{
return null;
}
try
{
return JsonSerializer.Deserialize<DateConfig>(config.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to deserialize DateConfig from database");
return null;
}
}
public async Task<DateOnly?> RandomizeDateAsync()
{
var dateSpan = await GetDateSpanAsync();
if (dateSpan == null || !dateSpan.IsValid)
{
logger.LogWarning("Cannot randomize date: no valid date span configured");
return null;
}
var randomDate = dateSpan.GetRandomDate(random);
var dateConfig = new DateConfig { TheDay = randomDate };
var jsonValue = JsonSerializer.Serialize(dateConfig, ConfigJsonSerializerContext.Default.DateConfig);
var config = await questionnaireContext.Configs.FirstOrDefaultAsync(c => c.Key == DateConfigKey);
if (config == null)
{
questionnaireContext.Configs.Add(new Config { Key = DateConfigKey, Value = jsonValue });
}
else
{
config.Value = jsonValue;
questionnaireContext.Configs.Update(config);
}
await questionnaireContext.SaveChangesAsync();
logger.LogInformation("Randomized date to {RandomDate}", randomDate);
return randomDate;
}
}
[JsonSerializable(typeof(DateSpan))]
[JsonSerializable(typeof(DateConfig))]
public sealed partial class ConfigJsonSerializerContext : JsonSerializerContext;
public static class DateServiceCollectionExtensions
{
public static IServiceCollection AddDateService(this IServiceCollection services)
{
services.TryAddSingleton(Random.Shared);
services.AddTransient<DateService>();
return services;
}
}
README.md +4 -3
diff --git a/README.md b/README.md
index 3c6c518..1aa7876 100644
@@ -3,13 +3,14 @@
## Features
- [ ] User pages
- [ ] See the date of **the day**
- [x] See the date of **the day**
- [ ] Hide the date by default
- [x] Submit questionnaire
- [x] Questionnaire only accepts phone numbers from configured participants
- [ ] Admin pages
- [x] Password protection
- [ ] Configure possible date span for **the day**
- [ ] (Re)roll date of **the day**
- [x] Configure possible date span for **the day**
- [x] (Re)roll date of **the day**
- [x] Configure participants
- [x] Phone number for notifications
- [ ] Time of day the want to be notified