Prepare worker and Discord snapshots through bounded catch-up

This commit is contained in:
syntaxbullet
2026-09-08 16:21:37 +02:00
parent c3b52e8fe1
commit 149ec36c77
8 changed files with 99 additions and 54 deletions

View File

@@ -3,7 +3,7 @@ import { cachedAvatar } from './avatar-cache';
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { existsSync } from "node:fs";
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { publicUser, type AppDatabase } from "../auth";
import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { addDays } from "../habits/calendar";
@@ -25,11 +25,16 @@ for (const [file, family] of [
}
}
export function liveCardData(db: AppDatabase, userId: string, now: number) {
export async function loadLiveCardData(db: AppDatabase, userId: string, now: () => number) {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, 'Account unavailable.');
return HabitService.withPrepared(db, publicUser(user), now, service => liveCardData(db, userId, service.now, service));
}
export function liveCardData(db: AppDatabase, userId: string, now: number, prepared?: HabitService) {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync();
const service = prepared ?? new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
if (!prepared) service.sync();
const from = addDays(service.today, -29);
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '')
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;

View File

@@ -8,7 +8,7 @@ import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar";
import { liveCardData, renderLiveCard } from "./live-card-image";
import { loadLiveCardData, renderLiveCard } from "./live-card-image";
import type { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes";
@@ -65,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
if (delay > 0) { schedule(id, delay); return; }
update(id, { dirty: false, retryAt: null });
const data = liveCardData(db, id, now());
const data = await loadLiveCardData(db, id, now);
// Hash the rendered content, excluding timestamps and private task details.
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) {
@@ -157,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme, c.get('user').id);
const image = await renderLiveCard(await loadLiveCardData(db, c.get('user').id, now), theme, c.get('user').id);
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image));
});

View File

@@ -78,17 +78,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
app.post("/preview", async c => {
const input = await json(c.req.raw, shareInput);
const service = new HabitService(db, c.get("user"), now()); service.sync();
if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id);
const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
return { name: habit.name, color: chart.settings.mainColor,
legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
}) };
const result = await HabitService.withPrepared(db, c.get("user"), now, service => {
if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id);
const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
return { name: habit.name, color: chart.settings.mainColor,
legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
}) };
return result;
});
result.botAvatarUrl = await botAvatarUrl();
return c.json(result);
});