Add a dayTintProvider plugin hook

Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
This commit is contained in:
Renzo Beux
2026-07-29 12:05:31 -03:00
committed by Maurice
parent 28ffc814c0
commit c89d2bf3a1
46 changed files with 664 additions and 17 deletions
@@ -0,0 +1,142 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../database/database.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
import { stripEmoji } from './text-sanitize';
/**
* GET /api/day-tints/:tripId — the colours the planner paints into a day card in the
* Plan sidebar (and into the mobile day chips) via the `dayTintProvider` hook. A trip
* split into colour-coded legs — Tokyo, Kanazawa, Kyoto — becomes readable at a
* glance while scrolling the itinerary.
*
* The card exposes three separately tintable regions — the day-number badge, the
* header row, and the expanded activity list — so a plugin can mark the leg boldly on
* the badge while leaving the dense activity list plain. `tone` is the shorthand that
* fills every region a contribution does not name.
*
* Why this is its own hook rather than one dayScheduleProvider item per day: that
* hook is capped at MAX_ITEMS = 60 per provider, so a six-month trip would tint its
* first 60 days and silently stop — failing exactly the long multi-destination trips
* this is for. Here the natural bound is the trip's own day count, because a day
* gets at most one tint.
*
* Same contract as the other provider hooks: additive + fail-safe, declarative data
* only, strings sanitized + capped. dayIds are checked against the trip so a
* stale/hand-edited contribution can never tint another trip's day.
*/
type Tone = 'default' | 'success' | 'warn' | 'danger';
export interface DayTint {
pluginId: string;
dayId: number;
/** Per-region tones, already resolved against the contribution's `tone` shorthand.
* An absent region is not tinted and renders exactly as it does without plugins. */
badgeTone?: Tone;
headerTone?: Tone;
activityTone?: Tone;
/** Optional tooltip on the tinted day ("Kanazawa leg"). */
label?: string;
}
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
// Bound the work, not just the output: an all-invalid raw array (no entry ever
// reaching `out`) would otherwise be iterated in full. Slice up front, well above
// any legitimate payload — a trip has one tint per day, and 2000 days is 5½ years.
const MAX_RAW_TINTS = 2000;
/**
* One region's tone. `undefined` means the region was not named, so the caller falls
* back to the shorthand; naming a region with a bogus value still tints it (as
* `default`) rather than silently doing nothing, because the plugin clearly asked for
* a tint there — same "unknown enum degrades, never fails the item" rule the other
* hooks use for `tone`.
*/
const region = (v: unknown): Tone | undefined =>
v === undefined || v === null ? undefined : (TONES.has(v as string) ? (v as Tone) : 'default');
function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unknown): DayTint[] {
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>).slice(0, MAX_RAW_TINTS) : [];
const out: DayTint[] = [];
// One tint per day per provider — a plugin that contradicts itself gets its first
// answer taken, not its last, so the day never depends on array order downstream.
const seen = new Set<number>();
for (const it of list) {
if (!it || typeof it !== 'object') continue;
const dayId = Number(it.dayId);
// A day outside this trip, or one already tinted by this provider — drop it.
if (!Number.isInteger(dayId) || !tripDayIds.has(dayId) || seen.has(dayId)) continue;
seen.add(dayId);
const label = cap(it.label, 60);
const shorthand = region(it.tone);
let badgeTone = region(it.badgeTone) ?? shorthand;
let headerTone = region(it.headerTone) ?? shorthand;
let activityTone = region(it.activityTone) ?? shorthand;
// An entry that names no region and carries no shorthand still means "tint this
// day" — the dayId is the payload, the colour is decoration. Give it the default
// tone everywhere rather than returning a contribution that paints nothing.
if (!badgeTone && !headerTone && !activityTone) badgeTone = headerTone = activityTone = 'default';
out.push({
pluginId,
dayId,
...(badgeTone ? { badgeTone } : {}),
...(headerTone ? { headerTone } : {}),
...(activityTone ? { activityTone } : {}),
...(label ? { label } : {}),
});
}
return out;
}
@Controller('api/day-tints')
@UseGuards(JwtAuthGuard)
export class DayTintsController {
constructor(
private readonly runtime: PluginRuntimeService,
private readonly dbs: DatabaseService,
) {}
@Get(':tripId')
async get(
@Param('tripId') tripIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ tints: DayTint[] }> {
if (!pluginsEnabled()) return { tints: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { tints: [] };
const ids = this.runtime.providersOf('dayTintProvider');
if (ids.length === 0) return { tints: [] };
const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>;
const tripDayIds: ReadonlySet<number> = new Set(dayRows.map((d) => d.id));
const perProvider = await Promise.all(
ids.map(async (id): Promise<DayTint[]> => {
try {
const raw = await this.runtime.invokeHook(id, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000);
return normalize(id, tripDayIds, raw);
} catch {
return []; // a slow / failing provider contributes nothing
}
}),
);
// Precedence across plugins: the first GRANTED provider wins a contested day.
// Promise.all preserves input order, so the flattened array is already in
// providersOf() order — keeping the first occurrence is the whole rule. This is
// deterministic, so a day can never flicker between two plugins' colours.
const claimed = new Set<number>();
const tints: DayTint[] = [];
for (const t of perProvider.flat()) {
if (claimed.has(t.dayId)) continue;
claimed.add(t.dayId);
tints.push(t);
}
return { tints };
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { MapMarkersController } from './map-markers.controller';
import { MapLayersController } from './map-layers.controller';
import { PluginRoutesController } from './plugin-routes.controller';
import { DayScheduleController } from './day-schedule.controller';
import { DayTintsController } from './day-tints.controller';
import { PluginActivityController } from './plugin-activity.controller';
import { PdfSectionsController } from './pdf-sections.controller';
import { AtlasLayersController } from './atlas-layers.controller';
@@ -56,7 +57,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, DayTintsController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
// required addon was just turned off (#plugins dependencies).
@@ -327,6 +327,7 @@ export const KNOWN_PERMISSIONS = [
'hook:map-layer-provider',
'hook:route-provider',
'hook:day-schedule-provider',
'hook:day-tint-provider',
'hook:pdf-section-provider',
'hook:atlas-layer-provider',
'hook:journal-entry-provider',
@@ -549,6 +549,27 @@ export interface DayScheduleProvider {
getSchedule(tripId: number, ctx: PluginContext): Promise<DayScheduleContribution[]>;
}
/** A colour the host paints into one day card in the Plan sidebar (and into that day's
* mobile chip) — so a trip split into legs shows its leg membership while you scroll
* the itinerary. Tone only, never a hex: the host picks the alpha per theme and per
* region. The card has three separately tintable regions; `tone` colours all of them. */
export interface DayTintContribution {
dayId: number; // must be a day of the requested trip
tone?: ContributionTone; // shorthand for every region not named below
badgeTone?: ContributionTone; // the day-number badge (and the mobile day chip)
headerTone?: ContributionTone; // the day header row
activityTone?: ContributionTone; // the expanded activity list
label?: string; // optional tooltip on the day (≤60 chars)
}
export interface DayTintProvider {
/** Return one entry per day you want coloured. Runs with the current user bound, on
* a short timeout; a failing call is skipped. A day takes at most one contribution,
* resolved whole: within your own list the first entry for a day wins, and across
* plugins the first granted provider wins, so a day never flickers between two
* colours and two plugins can never each own part of one card. */
getDayTints(tripId: number, ctx: PluginContext): Promise<DayTintContribution[]>;
}
/** A text-only section the host appends to a trip's PDF export. Declarative only —
* plain strings the host lays out and escapes; no markup ever reaches the document. */
export interface PdfSection {
@@ -659,6 +680,7 @@ export interface PluginDefinition {
mapLayerProvider?: MapLayerProvider;
routeProvider?: RouteProvider;
dayScheduleProvider?: DayScheduleProvider;
dayTintProvider?: DayTintProvider;
pdfSectionProvider?: PdfSectionProvider;
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
@@ -107,6 +107,7 @@ const HOOK_PERMISSION: Readonly<Record<string, string>> = {
mapLayerProvider: 'hook:map-layer-provider',
routeProvider: 'hook:route-provider',
dayScheduleProvider: 'hook:day-schedule-provider',
dayTintProvider: 'hook:day-tint-provider',
pdfSectionProvider: 'hook:pdf-section-provider',
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',