Show unseen Server commits after login.

Bake first-parent git log at Server build and remember the SHA in a browser cookie.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 11:43:36 +03:00
co-authored by Cursor
parent 1e7014cabd
commit 449be4e0ea
20 changed files with 751 additions and 1 deletions
+4
View File
@@ -21,6 +21,8 @@ const ru = {
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
sessionLogout: 'Выйти',
changelogTitle: 'Что нового',
changelogOk: 'Понятно',
schoolsTitle: 'Школы',
schoolsMine: 'Мои',
@@ -392,6 +394,8 @@ const en: Messages = {
sessionInvalidName: 'The name must be 140 characters.',
sessionFailed: 'Could not sign in. Try again.',
sessionLogout: 'Sign out',
changelogTitle: "What's new",
changelogOk: 'OK',
schoolsTitle: 'Schools',
schoolsMine: 'Mine',
+3
View File
@@ -6,6 +6,7 @@ import { GameScreen } from './ui/gameScreen.ts';
import { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts';
import { ensureSession } from './ui/sessionGate.ts';
import { showWhatsNew } from './ui/whatsNew.ts';
import type { School } from './net/api.ts';
import './style.css';
@@ -26,6 +27,7 @@ async function bootstrap(): Promise<void> {
footer.prepend(localeSwitch());
await ensureSession();
await showWhatsNew();
let openSchool: School | null = null;
let connectionStatus: ConnectionStatus = 'connecting';
@@ -109,6 +111,7 @@ async function bootstrap(): Promise<void> {
menu.stop();
await logoutSession();
await ensureSession();
await showWhatsNew();
connection.connect();
showMenu();
}
+18
View File
@@ -476,6 +476,24 @@ export async function logoutSession(): Promise<void> {
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
}
export interface ChangelogCommit {
readonly sha: string;
readonly date: string;
readonly subject: string;
}
export interface ChangelogResponse {
readonly current: string;
readonly commits: readonly ChangelogCommit[];
}
export async function fetchChangelog(since?: string | null): Promise<ChangelogResponse> {
const query = since !== null && since !== undefined && since.length > 0
? `?since=${encodeURIComponent(since)}`
: '';
return request<ChangelogResponse>(`/api/changelog${query}`);
}
export interface SwarmUiKindPreset {
width: number;
height: number;
+33
View File
@@ -1425,6 +1425,39 @@ body {
margin-top: 20px;
}
.dialog--changelog {
max-width: 520px;
}
.changelog__list {
max-height: min(50dvh, 360px);
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.changelog__item {
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.changelog__item:last-child {
border-bottom: none;
}
.changelog__date {
display: block;
color: var(--text-muted);
font-size: 12px;
}
.changelog__subject {
display: block;
margin-top: 2px;
line-height: 1.35;
}
.form {
display: flex;
flex-direction: column;
@@ -0,0 +1,23 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
const current = '0123456789abcdef0123456789abcdef01234567';
describe('seen revision cookie', () => {
afterEach(() => {
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
});
it('round-trips a 40-character SHA', () => {
writeSeenRevision(current);
expect(readSeenRevision()).toBe(current);
});
it('ignores a value that is not a SHA', () => {
writeSeenRevision('not-a-sha');
expect(readSeenRevision()).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
/** Last Server build SHA this browser already showed. Not the HttpOnly session cookie. */
export const SEEN_REVISION_COOKIE = 'hschool.seen-rev';
const SHA = /^[0-9a-f]{40}$/i;
const MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
export function readSeenRevision(): string | null {
const prefix = `${SEEN_REVISION_COOKIE}=`;
const parts = document.cookie.split(';');
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed.startsWith(prefix)) {
continue;
}
const value = decodeURIComponent(trimmed.slice(prefix.length));
return SHA.test(value) ? value.toLowerCase() : null;
}
return null;
}
export function writeSeenRevision(sha: string): void {
if (!SHA.test(sha)) {
return;
}
const pieces = [
`${SEEN_REVISION_COOKIE}=${encodeURIComponent(sha.toLowerCase())}`,
'Path=/',
'SameSite=Lax',
`Max-Age=${MAX_AGE_SECONDS}`,
];
if (globalThis.location?.protocol === 'https:') {
pieces.push('Secure');
}
document.cookie = pieces.join('; ');
}
@@ -0,0 +1,83 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchChangelog } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts';
import { showWhatsNew } from './whatsNew.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchChangelog: vi.fn(),
fetchSession: vi.fn(),
fetchSchools: vi.fn(),
};
});
const current = '0123456789abcdef0123456789abcdef01234567';
const previous = '89abcdef0123456789abcdef0123456789abcdef';
describe('whats-new after login', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
});
afterEach(() => {
vi.restoreAllMocks();
document.querySelector('dialog')?.remove();
document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`;
});
it('stores current without a dialog when there is no cookie', async () => {
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
await showWhatsNew();
expect(fetchChangelog).toHaveBeenCalledWith(null);
expect(readSeenRevision()).toBe(current);
expect(document.querySelector('dialog')).toBeNull();
});
it('does not open a dialog when the cookie already matches current', async () => {
writeSeenRevision(current);
vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] });
await showWhatsNew();
expect(fetchChangelog).toHaveBeenCalledWith(current);
expect(document.querySelector('dialog')).toBeNull();
});
it('opens the dialog for unseen commits and writes current on dismiss', async () => {
writeSeenRevision(previous);
vi.mocked(fetchChangelog).mockResolvedValue({
current,
commits: [
{
sha: current,
date: '2026-08-20T08:21:00+00:00',
subject: "Merge branch 'phase/53-weather-commute'",
},
],
});
const pending = showWhatsNew();
await Promise.resolve();
await Promise.resolve();
const dialog = document.querySelector('dialog');
expect(dialog?.textContent).toContain(t('changelogTitle'));
expect(dialog?.textContent).toContain("Merge branch 'phase/53-weather-commute'");
const ok = dialog?.querySelector('button');
ok?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await pending;
expect(readSeenRevision()).toBe(current);
expect(document.querySelector('dialog')).toBeNull();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { fetchChangelog, type ChangelogCommit } from '../net/api.ts';
import { intlTag } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
import { readSeenRevision, writeSeenRevision } from './seenRevision.ts';
/**
* After login: first visit stores the baked SHA; a newer build opens the changelog once.
* Returning from a school to the menu does not call this.
*/
export async function showWhatsNew(): Promise<void> {
try {
const changelog = await fetchChangelog(readSeenRevision());
if (!/^[0-9a-f]{40}$/i.test(changelog.current)) {
return;
}
if (changelog.commits.length > 0) {
await whatsNewDialog(changelog.commits);
}
writeSeenRevision(changelog.current);
} catch {
// Menu still works if the host skipped the embed or the request failed.
}
}
function whatsNewDialog(commits: readonly ChangelogCommit[]): Promise<void> {
const modal = new Modal(undefined);
const ok = el('button', {
class: 'button button--primary',
type: 'button',
text: t('changelogOk'),
onClick: () => modal.close(undefined),
});
const items = commits.map((commit) => {
const item = el('li', { class: 'changelog__item' });
const date = formatCommitDate(commit.date);
if (date !== null) {
item.append(el('time', { class: 'changelog__date', text: date }));
}
item.append(el('span', { class: 'changelog__subject', text: commit.subject }));
return item;
});
modal.element.classList.add('dialog--changelog');
modal.element.append(
el('h2', { class: 'dialog__title', text: t('changelogTitle') }),
el('ul', { class: 'changelog__list' }, ...items),
el('div', { class: 'dialog__actions' }, ok),
);
return modal.open(ok);
}
function formatCommitDate(iso: string): string | null {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return null;
}
return new Intl.DateTimeFormat(intlTag(), {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date);
}
@@ -0,0 +1,27 @@
using HSchool.Server.Changelog;
namespace HSchool.Server.Api;
internal static class ChangelogEndpoints
{
public static void MapChangelogEndpoints(this IEndpointRouteBuilder builder)
{
builder.MapGet("/api/changelog", GetAsync);
}
private static IResult GetAsync(string? since, BuildChangelog changelog)
{
var commits = changelog.VisibleSince(since)
.Select(commit => new ChangelogCommitResponse(
commit.Sha,
commit.Date.ToUniversalTime(),
commit.Subject))
.ToArray();
return Results.Json(new ChangelogResponse(changelog.CurrentSha, commits));
}
private sealed record ChangelogResponse(string Current, IReadOnlyList<ChangelogCommitResponse> Commits);
private sealed record ChangelogCommitResponse(string Sha, DateTimeOffset Date, string Subject);
}
@@ -0,0 +1,145 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace HSchool.Server.Changelog;
internal sealed record ChangelogCommit(string Sha, DateTimeOffset Date, string Subject);
/// <summary>First-parent git log baked into the Server assembly at compile time.</summary>
internal sealed partial class BuildChangelog
{
internal const string ResourceName = "HSchool.Server.changelog.tsv";
internal const int MaxVisibleCommits = 100;
private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false);
public BuildChangelog(string currentSha, IReadOnlyList<ChangelogCommit> commits)
{
CurrentSha = currentSha;
Commits = commits;
}
public string CurrentSha { get; }
/// <summary>Newest first, including <c>Mark phase</c> commits used as slice anchors.</summary>
public IReadOnlyList<ChangelogCommit> Commits { get; }
public static BuildChangelog LoadEmbedded()
{
var assembly = typeof(BuildChangelog).Assembly;
using var stream = assembly.GetManifestResourceStream(ResourceName);
if (stream is null)
{
return new BuildChangelog("", []);
}
using var reader = new StreamReader(stream, Utf8);
return Parse(reader.ReadToEnd());
}
public static BuildChangelog Parse(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return new BuildChangelog("", []);
}
var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0)
{
return new BuildChangelog("", []);
}
var current = IsSha(lines[0]) ? lines[0].ToLowerInvariant() : "";
var commits = new List<ChangelogCommit>();
foreach (var line in lines.Skip(1))
{
if (TryParseCommit(line, out var commit))
{
commits.Add(commit);
}
}
return new BuildChangelog(current, commits);
}
public IReadOnlyList<ChangelogCommit> VisibleSince(string? since)
{
if (string.IsNullOrWhiteSpace(since)
|| string.Equals(since, CurrentSha, StringComparison.OrdinalIgnoreCase))
{
return [];
}
var newer = new List<ChangelogCommit>();
var found = false;
foreach (var commit in Commits)
{
if (string.Equals(commit.Sha, since, StringComparison.OrdinalIgnoreCase))
{
found = true;
break;
}
newer.Add(commit);
}
if (!found)
{
return [];
}
newer.Reverse();
var visible = new List<ChangelogCommit>();
foreach (var commit in newer)
{
if (IsMarkPhase(commit.Subject))
{
continue;
}
visible.Add(commit);
if (visible.Count >= MaxVisibleCommits)
{
break;
}
}
return visible;
}
internal static bool IsMarkPhase(string subject) => MarkPhasePattern().IsMatch(subject);
private static bool TryParseCommit(string line, out ChangelogCommit commit)
{
commit = null!;
var parts = line.Split('\x1f');
if (parts.Length < 3 || !IsSha(parts[0]))
{
return false;
}
if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
{
return false;
}
var subject = string.Join('\x1f', parts.Skip(2)).Trim();
if (subject.Length == 0)
{
return false;
}
commit = new ChangelogCommit(parts[0].ToLowerInvariant(), date, subject);
return true;
}
private static bool IsSha(string value) => ShaPattern().IsMatch(value);
[GeneratedRegex(@"^[0-9a-fA-F]{40}$", RegexOptions.CultureInvariant)]
private static partial Regex ShaPattern();
[GeneratedRegex(@"^Mark phase \d+", RegexOptions.CultureInvariant)]
private static partial Regex MarkPhasePattern();
}
+22
View File
@@ -32,4 +32,26 @@
<InternalsVisibleTo Include="HSchool.Server.Tests" />
</ItemGroup>
<PropertyGroup>
<HSchoolRepoRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..'))</HSchoolRepoRoot>
</PropertyGroup>
<!-- Bake git HEAD + first-parent log; the running process does not call git. -->
<Target Name="EmbedGitChangelog" BeforeTargets="CreateManifestResourceNames" Condition="'$(DesignTimeBuild)' != 'true'">
<MakeDir Directories="$(IntermediateOutputPath)" />
<PropertyGroup>
<_ChangelogTsv>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)changelog.tsv'))</_ChangelogTsv>
</PropertyGroup>
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))" Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(HSchoolRepoRoot)\tools\embed-changelog.ps1&quot; -RepoRoot &quot;$(HSchoolRepoRoot)&quot; -Output &quot;$(_ChangelogTsv)&quot;" />
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))" Command="sh &quot;$(HSchoolRepoRoot)/tools/embed-changelog.sh&quot; &quot;$(HSchoolRepoRoot)&quot; &quot;$(_ChangelogTsv)&quot;" />
<ItemGroup>
<EmbeddedResource Include="$(_ChangelogTsv)" Condition="Exists('$(_ChangelogTsv)')">
<LogicalName>HSchool.Server.changelog.tsv</LogicalName>
<Type>Non-Resx</Type>
<WithCulture>false</WithCulture>
</EmbeddedResource>
<FileWrites Include="$(_ChangelogTsv)" />
</ItemGroup>
</Target>
</Project>
+3
View File
@@ -1,6 +1,7 @@
using System.Net.WebSockets;
using HSchool.Server;
using HSchool.Server.Api;
using HSchool.Server.Changelog;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Server.Session;
@@ -42,6 +43,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded());
builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<ModContent>();
@@ -79,6 +81,7 @@ app.UseWebSockets(new WebSocketOptions
app.UseMiddleware<SessionAuthMiddleware>();
app.MapSessionEndpoints();
app.MapChangelogEndpoints();
app.MapSchoolEndpoints();
app.MapSettingsEndpoints();
app.MapTimetableEndpoints();