mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user