('go')
const [browseFromEdit, setBrowseFromEdit] = useState(false)
@@ -300,12 +305,18 @@ export default function MTripShell({
{days.map((day, idx) => {
const active = day.id === planner.selectedDayId
+ const tint = dayTints[day.id]
return (
onDayChipTap(day.id)}
aria-current={active ? 'true' : undefined}
+ title={tint?.label || undefined}
+ // The chip is mobile's day-number badge, so it follows badgeTone.
+ // Inactive chips only — an inline background would otherwise beat
+ // the active chip's bg-m-act class.
+ style={active ? undefined : { background: dayTintBackground(tint?.badgeTone, '--day-tint-chip') }}
className={`flex-1 whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]' : 'text-m-ink'
}`}
diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts
index 2f3d1e21b..f8a0955dd 100644
--- a/plugin-sdk/src/cli/ui.ts
+++ b/plugin-sdk/src/cli/ui.ts
@@ -218,6 +218,7 @@ export const PERMISSION_FAMILIES: PermissionFamily[] = [
{ value: 'hook:map-layer-provider', hint: 'Draw routes, corridors and zones on the trip map' },
{ value: 'hook:route-provider', hint: 'Offer routing profiles the planner can route days with (e.g. EV with charging stops)' },
{ value: 'hook:day-schedule-provider', hint: 'Attach time contributions to the day plan (charging, security, buffers)' },
+ { value: 'hook:day-tint-provider', hint: 'Colour-code days in the day plan (e.g. which leg of the trip each day belongs to)' },
{ value: 'hook:pdf-section-provider', hint: 'Add a section to the exported trip PDF' },
{ value: 'hook:atlas-layer-provider', hint: 'Add a layer to the Atlas map' },
{ value: 'hook:journal-entry-provider', hint: 'Contribute entries to a journey' },
diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts
index 635f8e4c1..423ea0244 100644
--- a/plugin-sdk/src/index.ts
+++ b/plugin-sdk/src/index.ts
@@ -585,6 +585,42 @@ export interface DayScheduleProvider {
getSchedule(tripId: number, ctx: PluginContext): Promise;
}
+/** 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, so the tint stays subtle and readable in both light and dark.
+ *
+ * The card has three separately tintable regions. Set `tone` to colour all of them,
+ * or name regions individually for finer control — e.g. a bold badge marking the leg
+ * with the activity list left plain, so a dense day stays easy to read. */
+export interface DayTintContribution {
+ dayId: number; // must be a day of the requested trip
+ /** Shorthand: tints every region you do NOT name below. Omit it and only the
+ * regions you name are tinted. */
+ tone?: ContributionTone;
+ /** The day-number badge — the smallest, boldest mark. Also tints the mobile day chip. */
+ badgeTone?: ContributionTone;
+ /** The day header row (number, title, date, cost). Its hover state deepens the tint. */
+ headerTone?: ContributionTone;
+ /** The expanded activity list — places, bookings and transport for the day. The
+ * largest surface and the one behind the densest text, so it is tinted faintest. */
+ activityTone?: ContributionTone;
+ label?: string; // optional tooltip on the day (≤60 chars)
+}
+export interface DayTintProvider {
+ /** Return one tint per day you want coloured — the natural cap is the trip's day
+ * count, so this scales to a six-month itinerary where a per-day dayScheduleProvider
+ * item would hit the ≤60 limit. Runs with the current user bound, on a short
+ * timeout; a failing call is skipped.
+ *
+ * A day takes at most one contribution — it is resolved whole, per day, never
+ * per region. Within your own list the first entry for a day wins; across plugins
+ * the first granted provider wins. Both are deterministic, so a day never flickers
+ * between two colours, and two plugins can never each own part of one card.
+ * Needs `hook:day-tint-provider`. */
+ getDayTints(tripId: number, ctx: PluginContext): Promise;
+}
+
/** 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 {
@@ -703,6 +739,7 @@ export interface PluginDefinition {
mapLayerProvider?: MapLayerProvider;
routeProvider?: RouteProvider;
dayScheduleProvider?: DayScheduleProvider;
+ dayTintProvider?: DayTintProvider;
pdfSectionProvider?: PdfSectionProvider;
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts
index 48aa05787..a2b5e4778 100644
--- a/plugin-sdk/src/manifest.ts
+++ b/plugin-sdk/src/manifest.ts
@@ -89,7 +89,7 @@ export const KNOWN_PERMISSIONS = [
'db:meta',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider',
- 'hook:table-contributor', 'hook:map-marker-provider', 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:pdf-section-provider', 'hook:atlas-layer-provider',
+ 'hook:table-contributor', 'hook:map-marker-provider', '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', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data',
'events:subscribe', 'jobs:run', 'http:outbound',
'weather:read', 'rates:read', 'notify:send', 'ai:invoke', 'oauth:client',
diff --git a/plugin-sdk/src/permissions.ts b/plugin-sdk/src/permissions.ts
index 2a282a389..fe1984f51 100644
--- a/plugin-sdk/src/permissions.ts
+++ b/plugin-sdk/src/permissions.ts
@@ -28,6 +28,7 @@ export const HOOK_PERMISSION: Readonly> = {
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',
diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts
index 9ceecdc29..4103b745c 100644
--- a/plugin-sdk/test/sdk.test.ts
+++ b/plugin-sdk/test/sdk.test.ts
@@ -86,8 +86,8 @@ describe('validateManifest', () => {
expect(r.manifest?.permissions).toEqual(permissions);
});
- it('accepts hook:map-layer-provider, hook:day-schedule-provider and geolocation:read', () => {
- const r = validateManifest({ ...base, permissions: ['hook:map-layer-provider', 'hook:day-schedule-provider', 'geolocation:read'] });
+ it('accepts hook:map-layer-provider, hook:day-schedule-provider, hook:day-tint-provider and geolocation:read', () => {
+ const r = validateManifest({ ...base, permissions: ['hook:map-layer-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read'] });
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
});
diff --git a/server/src/nest/plugins/day-tints.controller.ts b/server/src/nest/plugins/day-tints.controller.ts
new file mode 100644
index 000000000..11ba6973a
--- /dev/null
+++ b/server/src/nest/plugins/day-tints.controller.ts
@@ -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 = 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, raw: unknown): DayTint[] {
+ const list = Array.isArray(raw) ? (raw as Array>).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();
+ 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 = new Set(dayRows.map((d) => d.id));
+
+ const perProvider = await Promise.all(
+ ids.map(async (id): Promise => {
+ 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();
+ const tints: DayTint[] = [];
+ for (const t of perProvider.flat()) {
+ if (claimed.has(t.dayId)) continue;
+ claimed.add(t.dayId);
+ tints.push(t);
+ }
+ return { tints };
+ }
+}
diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts
index b70a895c9..bf3bc052b 100644
--- a/server/src/nest/plugins/plugins.module.ts
+++ b/server/src/nest/plugins/plugins.module.ts
@@ -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).
diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts
index a1121db26..68dc7469d 100644
--- a/server/src/nest/plugins/protocol/envelope.ts
+++ b/server/src/nest/plugins/protocol/envelope.ts
@@ -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',
diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts
index 01a24be29..3d81d74d1 100644
--- a/server/src/nest/plugins/runtime/plugin-sdk.ts
+++ b/server/src/nest/plugins/runtime/plugin-sdk.ts
@@ -549,6 +549,27 @@ export interface DayScheduleProvider {
getSchedule(tripId: number, ctx: PluginContext): Promise;
}
+/** 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;
+}
+
/** 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;
diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts
index a77a8b570..018ae510e 100644
--- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts
+++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts
@@ -107,6 +107,7 @@ const HOOK_PERMISSION: Readonly> = {
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',
diff --git a/server/tests/unit/plugins/day-tints.controller.test.ts b/server/tests/unit/plugins/day-tints.controller.test.ts
new file mode 100644
index 000000000..877c424a9
--- /dev/null
+++ b/server/tests/unit/plugins/day-tints.controller.test.ts
@@ -0,0 +1,170 @@
+/**
+ * DayTintsController (#plugins): the per-day colour plugins put behind the day cards
+ * via the dayTintProvider hook. Same gate + fail-safe + server-side normalization as
+ * the day-schedule hook, plus the rule this hook exists to make deterministic: a day
+ * takes exactly one tint, so a contested day must resolve the same way every request
+ * or the card flickers between two plugins' colours.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { canAccessTrip, pluginsEnabled, tripDays } = vi.hoisted(() => ({
+ canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
+ pluginsEnabled: vi.fn(() => true),
+ tripDays: { value: [{ id: 10 }, { id: 11 }] as Array<{ id: number }> },
+}));
+vi.mock('../../../src/db/database', () => ({
+ db: { prepare: () => ({ all: () => tripDays.value }) },
+ canAccessTrip,
+}));
+import { db as dbConn } from '../../../src/db/database';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
+
+import { DayTintsController } from '../../../src/nest/plugins/day-tints.controller';
+import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
+function controller(invoke: (id: string) => unknown, providers = ['p1']) {
+ const runtime = {
+ providersOf: vi.fn(() => providers),
+ invokeHook: vi.fn(async (id: string) => invoke(id)),
+ } as unknown as PluginRuntimeService;
+ return { c: new DayTintsController(runtime, new DatabaseService(dbConn)), runtime };
+}
+const tint = (over: Record = {}) => ({ dayId: 10, tone: 'success', ...over });
+
+describe('DayTintsController', () => {
+ beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockReturnValue({ id: 1 } as never); });
+
+ it('gates: disabled / no user / non-member all return [] (no plugin calls on the first)', async () => {
+ pluginsEnabled.mockReturnValue(false);
+ const x = controller(() => [tint()]);
+ expect(await x.c.get('1', req(5))).toEqual({ tints: [] });
+ expect(x.runtime.providersOf).not.toHaveBeenCalled();
+ pluginsEnabled.mockReturnValue(true);
+
+ expect((await controller(() => [tint()]).c.get('1', req(undefined))).tints).toEqual([]);
+ canAccessTrip.mockReturnValue(undefined as never);
+ expect((await controller(() => [tint()]).c.get('1', req(5))).tints).toEqual([]);
+ });
+
+ it('keeps a valid tint, stamps pluginId, and caps the label', async () => {
+ const { c } = controller(() => [tint({ label: 'x'.repeat(200) })]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out).toHaveLength(1);
+ expect(out[0]).toMatchObject({ pluginId: 'p1', dayId: 10 });
+ expect(out[0].label).toHaveLength(60);
+ });
+
+ it('spreads the `tone` shorthand across all three regions', async () => {
+ const { c } = controller(() => [tint({ tone: 'warn' })]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out[0]).toMatchObject({ badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' });
+ });
+
+ it('lets a named region override the shorthand', async () => {
+ const { c } = controller(() => [tint({ tone: 'warn', badgeTone: 'danger' })]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out[0]).toMatchObject({ badgeTone: 'danger', headerTone: 'warn', activityTone: 'warn' });
+ });
+
+ it('tints ONLY the named regions when there is no shorthand', async () => {
+ // The point of the split: mark the leg on the badge, leave the dense activity
+ // list untouched. An absent region must stay absent, not fall back to a colour.
+ const { c } = controller(() => [{ dayId: 10, badgeTone: 'success' }]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeTone: 'success' });
+ expect(out[0].headerTone).toBeUndefined();
+ expect(out[0].activityTone).toBeUndefined();
+ });
+
+ it('degrades a bogus tone to default rather than dropping the region', async () => {
+ const { c } = controller(() => [
+ tint({ tone: 'chartreuse' }),
+ { dayId: 11, headerTone: 42 },
+ ]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out[0]).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
+ // A named-but-bogus region is still a request to tint it; unnamed ones stay off.
+ expect(out[1]).toEqual({ pluginId: 'p1', dayId: 11, headerTone: 'default' });
+ });
+
+ it('treats an entry with no tone at all as "tint this day", default everywhere', async () => {
+ const { c } = controller(() => [{ dayId: 10 }, { dayId: 11, tone: undefined }]);
+ const out = (await c.get('1', req(5))).tints;
+ for (const t of out) expect(t).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
+ expect(out[0].label).toBeUndefined();
+ });
+
+ it("drops tints on another trip's day, on a non-numeric day, and non-objects", async () => {
+ const { c } = controller(() => [
+ tint({ dayId: 999 }), // not a day of this trip
+ tint({ dayId: 'x' }), // non-numeric day
+ tint({ dayId: null }),
+ null, // non-object
+ tint({ dayId: 11 }),
+ ]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out.map(t => t.dayId)).toEqual([11]);
+ });
+
+ it('takes a provider\'s FIRST answer for a day it tints twice', async () => {
+ const { c } = controller(() => [
+ tint({ dayId: 10, tone: 'success', label: 'first' }),
+ tint({ dayId: 10, tone: 'danger', label: 'second' }),
+ ]);
+ const out = (await c.get('1', req(5))).tints;
+ expect(out).toHaveLength(1);
+ expect(out[0]).toMatchObject({ badgeTone: 'success', label: 'first' });
+ });
+
+ it('resolves a contested day WHOLE — the loser cannot fill in the winner\'s empty regions', async () => {
+ const { c } = controller(
+ (id) => (id === 'first'
+ ? [{ dayId: 10, badgeTone: 'success' }] // badge only
+ : [{ dayId: 10, tone: 'danger' }, { dayId: 11, tone: 'warn' }]), // would fill all three
+ ['first', 'second'],
+ );
+ const out = (await c.get('1', req(5))).tints;
+ // Day 10 is `first`'s badge-only contribution — `second` contributes nothing to
+ // it, so two plugins can never each own part of one card. Day 11 is uncontested.
+ expect(out).toEqual([
+ { pluginId: 'first', dayId: 10, badgeTone: 'success' },
+ { pluginId: 'second', dayId: 11, badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' },
+ ]);
+ });
+
+ it('skips a failing provider without losing the healthy one', async () => {
+ const { c } = controller(
+ (id) => (id === 'bad' ? (() => { throw new Error('boom'); })() : [tint()]),
+ ['bad', 'good'],
+ );
+ const out = (await c.get('1', req(5))).tints;
+ expect(out.map(t => t.pluginId)).toEqual(['good']);
+ });
+
+ it('bounds work on an all-invalid oversized payload', async () => {
+ const huge = Array.from({ length: 100_000 }, () => ({ dayId: 999, tone: 'success' })); // foreign day -> all dropped
+ const { c } = controller(() => huge);
+ expect((await c.get('1', req(5))).tints).toEqual([]);
+ });
+
+ it('slices the raw array at 2000 before validating it', async () => {
+ // 2000 junk entries, then a legitimate one — past the slice, so it never lands.
+ const raw = [...Array.from({ length: 2000 }, () => ({ dayId: 999 })), tint({ dayId: 11 })];
+ const { c } = controller(() => raw);
+ expect((await c.get('1', req(5))).tints).toEqual([]);
+ });
+
+ it('ignores a non-array payload', async () => {
+ const { c } = controller(() => ({ tints: [tint()] }));
+ expect((await c.get('1', req(5))).tints).toEqual([]);
+ });
+
+ it('skips the day lookup entirely when no provider is active', async () => {
+ const { c, runtime } = controller(() => [tint()], []);
+ expect((await c.get('1', req(5))).tints).toEqual([]);
+ expect(runtime.invokeHook).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/tests/unit/plugins/provider-hook-grant.test.ts b/server/tests/unit/plugins/provider-hook-grant.test.ts
index faacb0267..404ed8731 100644
--- a/server/tests/unit/plugins/provider-hook-grant.test.ts
+++ b/server/tests/unit/plugins/provider-hook-grant.test.ts
@@ -74,6 +74,20 @@ describe('providersOf enforces the hook:* grant', () => {
expect(s.providersOf('dayScheduleProvider')).toEqual(['times']);
});
+ it('maps dayTintProvider to hook:day-tint-provider', () => {
+ const s = makeSupervisor();
+ put(s, 'segments', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
+ put(s, 'ungranted', 'active', ['dayTintProvider'], ['hook:day-schedule-provider']); // wrong grant
+ expect(s.providersOf('dayTintProvider')).toEqual(['segments']);
+ });
+
+ it('returns tint providers in insertion order — the controller lets the first win a contested day', () => {
+ const s = makeSupervisor();
+ put(s, 'first', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
+ put(s, 'second', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
+ expect(s.providersOf('dayTintProvider')).toEqual(['first', 'second']);
+ });
+
it('maps notificationChannel to hook:notification-channel', () => {
const s = makeSupervisor();
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts
index 08dac899d..d89bcd446 100644
--- a/shared/src/i18n/ar/admin.ts
+++ b/shared/src/i18n/ar/admin.ts
@@ -331,6 +331,8 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider':
'توفير ملفات تعريف توجيه يحسب بها المخطط مسارات الأيام (مثل توجيه السيارات الكهربائية مع توقفات للشحن)',
'admin.plugins.perm.hook:day-schedule-provider': 'إلحاق مدخلات زمنية بخطة اليوم (توقفات للشحن، هوامش احتياطية)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'تلوين الأيام في خطة اليوم (مثلاً تحديد المرحلة التي ينتمي إليها كل يوم)',
'admin.plugins.perm.geolocation:read':
'الاستعلام عن موقعك المباشر ما دامت إحدى واجهاته مفتوحة (سيطلب متصفحك الإذن أيضًا)',
'admin.plugins.perm.hook:pdf-section-provider': 'إضافة أقسام نصية إلى تصدير PDF للرحلة',
@@ -452,6 +454,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'يرسم على الخريطة',
'admin.plugins.cap.routing': 'يوفّر التوجيه',
'admin.plugins.cap.daySchedule': 'يضيف أوقات الخطة',
+ 'admin.plugins.cap.dayTint': 'يلوّن الأيام',
'admin.plugins.cap.geolocation': 'يقرأ موقعك',
'admin.plugins.cap.events': 'يتفاعل مع النشاط',
'admin.plugins.cap.requiresAddon': 'يتطلب {addon}',
diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts
index 115b09307..b8ba99adf 100644
--- a/shared/src/i18n/br/admin.ts
+++ b/shared/src/i18n/br/admin.ts
@@ -317,6 +317,8 @@ const admin: TranslationStrings = {
'Oferecer perfis de roteamento com os quais o planejador calcula os dias (ex. rotas para carro elétrico com paradas de recarga)',
'admin.plugins.perm.hook:day-schedule-provider':
'Anexar entradas de horário ao plano do dia (paradas de recarga, margens de segurança)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Colorir os dias no plano do dia (por exemplo, a qual trecho da viagem um dia pertence)',
'admin.plugins.perm.geolocation:read':
'Consultar sua localização em tempo real enquanto uma das visualizações dele estiver aberta (seu navegador ainda pedirá permissão)',
'admin.plugins.perm.hook:pdf-section-provider': 'Anexar seções de texto à exportação em PDF da viagem',
@@ -441,6 +443,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Desenha no mapa',
'admin.plugins.cap.routing': 'Oferece roteamento',
'admin.plugins.cap.daySchedule': 'Adiciona horários ao plano',
+ 'admin.plugins.cap.dayTint': 'Colore os dias',
'admin.plugins.cap.geolocation': 'Lê sua localização',
'admin.plugins.cap.events': 'Reage a atividades',
'admin.plugins.cap.requiresAddon': 'Requer {addon}',
diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts
index 0c046c52a..feed4c42a 100644
--- a/shared/src/i18n/ca/admin.ts
+++ b/shared/src/i18n/ca/admin.ts
@@ -461,6 +461,8 @@ const admin: TranslationStrings = {
'Oferir perfils de ruta amb què el planificador pot calcular els dies (p. ex. rutes per a cotxe elèctric amb parades de recàrrega)',
'admin.plugins.perm.hook:day-schedule-provider':
'Afegir entrades horàries al pla del dia (parades de recàrrega, marges de seguretat)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Acolorir els dies del pla del dia (per exemple, a quin tram del viatge pertany un dia)',
'admin.plugins.perm.geolocation:read':
"Consultar la teva ubicació en temps real mentre una de les seves vistes és oberta (el teu navegador també t'ho demanarà)",
'admin.plugins.updateConsentTitle': 'Aquesta actualització necessita permisos nous',
@@ -566,6 +568,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Dibuixa al mapa',
'admin.plugins.cap.routing': 'Calcula rutes',
'admin.plugins.cap.daySchedule': 'Afegeix horaris al pla',
+ 'admin.plugins.cap.dayTint': 'Acoloreix els dies',
'admin.plugins.cap.geolocation': 'Llegeix la teva ubicació',
'admin.plugins.cap.events': "Reacciona a l'activitat",
'admin.plugins.cap.requiresAddon': 'Requereix {addon}',
diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts
index 80b1c51a4..701dcd50b 100644
--- a/shared/src/i18n/cs/admin.ts
+++ b/shared/src/i18n/cs/admin.ts
@@ -318,6 +318,8 @@ const admin: TranslationStrings = {
'Nabízet trasovací profily, podle kterých plánovač počítá trasy dnů (např. trasování pro elektromobily se zastávkami na nabíjení)',
'admin.plugins.perm.hook:day-schedule-provider':
'Připojovat časové položky k dennímu plánu (zastávky na nabíjení, časové rezervy)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Barevně odlišovat dny v denním plánu (například ke které části cesty den patří)',
'admin.plugins.perm.geolocation:read':
'Zjišťovat vaši aktuální polohu, dokud je otevřené některé z jeho zobrazení (váš prohlížeč se navíc zeptá sám)',
'admin.plugins.perm.hook:pdf-section-provider': 'Připojovat textové sekce k PDF exportu cesty',
@@ -439,6 +441,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Kreslí na mapě',
'admin.plugins.cap.routing': 'Nabízí trasování',
'admin.plugins.cap.daySchedule': 'Doplňuje časy plánu',
+ 'admin.plugins.cap.dayTint': 'Barví dny',
'admin.plugins.cap.geolocation': 'Čte vaši polohu',
'admin.plugins.cap.events': 'Reaguje na aktivitu',
'admin.plugins.cap.requiresAddon': 'Vyžaduje {addon}',
diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts
index a2d316d3e..770328629 100644
--- a/shared/src/i18n/de/admin.ts
+++ b/shared/src/i18n/de/admin.ts
@@ -323,6 +323,8 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider':
'Routing-Profile anbieten, mit denen der Planer Tage berechnet (z. B. E-Auto-Routing mit Ladestopps)',
'admin.plugins.perm.hook:day-schedule-provider': 'Zeiteinträge an den Tagesplan anhängen (Ladestopps, Puffer)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Tage im Tagesplan farblich markieren (z. B. zu welchem Reiseabschnitt ein Tag gehört)',
'admin.plugins.perm.geolocation:read':
'Deinen Live-Standort abfragen, solange eine seiner Ansichten offen ist (dein Browser fragt zusätzlich nach)',
'admin.plugins.perm.hook:pdf-section-provider': 'Textabschnitte an den PDF-Export einer Reise anhängen',
@@ -448,6 +450,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Zeichnet auf der Karte',
'admin.plugins.cap.routing': 'Bietet Routing an',
'admin.plugins.cap.daySchedule': 'Ergänzt Planzeiten',
+ 'admin.plugins.cap.dayTint': 'Färbt Tage ein',
'admin.plugins.cap.geolocation': 'Liest deinen Standort',
'admin.plugins.cap.events': 'Reagiert auf Aktivität',
'admin.plugins.cap.requiresAddon': 'Benötigt {addon}',
diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts
index e107cb8bd..450c9cf1b 100644
--- a/shared/src/i18n/en/admin.ts
+++ b/shared/src/i18n/en/admin.ts
@@ -361,6 +361,8 @@ const admin: TranslationStrings = {
'Offer routing profiles the planner can route days with (e.g. EV routing with charging stops)',
'admin.plugins.perm.hook:day-schedule-provider':
'Attach time entries to the day plan (charging stops, security buffers)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Colour-code days in the day plan (e.g. which leg of the trip a day belongs to)',
'admin.plugins.perm.geolocation:read':
'Ask for your live position while one of its views is open (your browser will still ask first)',
'admin.plugins.perm.hook:pdf-section-provider': 'Append text sections to the trip PDF export',
@@ -483,6 +485,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Draws on the map',
'admin.plugins.cap.routing': 'Offers routing',
'admin.plugins.cap.daySchedule': 'Adds plan times',
+ 'admin.plugins.cap.dayTint': 'Tints days',
'admin.plugins.cap.geolocation': 'Reads your position',
'admin.plugins.cap.events': 'Reacts to activity',
'admin.plugins.cap.requiresAddon': 'Requires {addon}',
diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts
index ec89cf708..2fcbe7007 100644
--- a/shared/src/i18n/es/admin.ts
+++ b/shared/src/i18n/es/admin.ts
@@ -326,6 +326,8 @@ const admin: TranslationStrings = {
'Ofrecer perfiles de ruta con los que el planificador puede calcular los días (p. ej. rutas para vehículos eléctricos con paradas de carga)',
'admin.plugins.perm.hook:day-schedule-provider':
'Añadir entradas de tiempo al plan del día (paradas de carga, márgenes de seguridad)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Colorear los días del plan del día (por ejemplo, a qué tramo del viaje pertenece un día)',
'admin.plugins.perm.geolocation:read':
'Consultar tu ubicación en tiempo real mientras una de sus vistas esté abierta (tu navegador también pedirá permiso)',
'admin.plugins.perm.hook:pdf-section-provider': 'Añadir secciones de texto a la exportación PDF del viaje',
@@ -450,6 +452,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Dibuja en el mapa',
'admin.plugins.cap.routing': 'Ofrece rutas',
'admin.plugins.cap.daySchedule': 'Añade horarios al plan',
+ 'admin.plugins.cap.dayTint': 'Colorea los días',
'admin.plugins.cap.geolocation': 'Lee tu ubicación',
'admin.plugins.cap.events': 'Reacciona a la actividad',
'admin.plugins.cap.requiresAddon': 'Requiere {addon}',
diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts
index 50a09fa7d..ea861349e 100644
--- a/shared/src/i18n/fr/admin.ts
+++ b/shared/src/i18n/fr/admin.ts
@@ -328,6 +328,8 @@ const admin: TranslationStrings = {
'Proposer des profils de routage avec lesquels le planificateur calcule les itinéraires des jours (p. ex. routage pour véhicule électrique avec arrêts de recharge)',
'admin.plugins.perm.hook:day-schedule-provider':
'Ajouter des entrées horaires au plan de la journée (arrêts de recharge, marges de sécurité)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Colorer les jours du plan de la journée (par exemple, à quelle étape du voyage appartient un jour)',
'admin.plugins.perm.geolocation:read':
"Demander votre position en temps réel tant que l'une de ses vues est ouverte (votre navigateur vous demandera d'abord l'autorisation)",
'admin.plugins.perm.hook:pdf-section-provider': "Ajouter des sections de texte à l'export PDF du voyage",
@@ -453,6 +455,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Dessine sur la carte',
'admin.plugins.cap.routing': 'Propose des itinéraires',
'admin.plugins.cap.daySchedule': 'Ajoute des horaires',
+ 'admin.plugins.cap.dayTint': 'Colore les jours',
'admin.plugins.cap.geolocation': 'Lit votre position',
'admin.plugins.cap.events': 'Réagit à l’activité',
'admin.plugins.cap.requiresAddon': 'Nécessite {addon}',
diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts
index 772821f89..ed00761b4 100644
--- a/shared/src/i18n/gr/admin.ts
+++ b/shared/src/i18n/gr/admin.ts
@@ -376,6 +376,8 @@ const admin: TranslationStrings = {
'Παροχή προφίλ δρομολόγησης με τα οποία ο σχεδιαστής υπολογίζει τις διαδρομές των ημερών (π.χ. δρομολόγηση ηλεκτρικού οχήματος με στάσεις φόρτισης)',
'admin.plugins.perm.hook:day-schedule-provider':
'Προσθήκη χρονικών καταχωρίσεων στο πλάνο της ημέρας (στάσεις φόρτισης, περιθώρια ασφαλείας)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Χρωματική σήμανση ημερών στο πλάνο της ημέρας (π.χ. σε ποιο σκέλος του ταξιδιού ανήκει μια ημέρα)',
'admin.plugins.perm.geolocation:read':
'Αίτημα της τρέχουσας τοποθεσίας σας όσο μία από τις προβολές του είναι ανοιχτή (το πρόγραμμα περιήγησής σας θα ζητήσει επιπλέον επιβεβαίωση)',
'admin.plugins.perm.hook:pdf-section-provider': 'Προσθήκη ενοτήτων κειμένου στην εξαγωγή PDF του ταξιδιού',
@@ -501,6 +503,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Σχεδιάζει στον χάρτη',
'admin.plugins.cap.routing': 'Παρέχει δρομολόγηση',
'admin.plugins.cap.daySchedule': 'Προσθέτει χρόνους πλάνου',
+ 'admin.plugins.cap.dayTint': 'Χρωματίζει τις ημέρες',
'admin.plugins.cap.geolocation': 'Διαβάζει την τοποθεσία σας',
'admin.plugins.cap.events': 'Αντιδρά σε δραστηριότητα',
'admin.plugins.cap.requiresAddon': 'Απαιτεί {addon}',
diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts
index 91bdf4eae..6e0ecf0d9 100644
--- a/shared/src/i18n/hu/admin.ts
+++ b/shared/src/i18n/hu/admin.ts
@@ -329,6 +329,8 @@ const admin: TranslationStrings = {
'Útvonaltervezési profilok biztosítása, amelyekkel a tervező kiszámítja a napok útvonalát (pl. e-autós útvonaltervezés töltőmegállókkal)',
'admin.plugins.perm.hook:day-schedule-provider':
'Időbejegyzések hozzáadása a napi tervhez (töltőmegállók, biztonsági ráhagyások)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'A napok színezése a napi tervben (például hogy az út melyik szakaszához tartozik egy nap)',
'admin.plugins.perm.geolocation:read':
'Az élő tartózkodási helyed lekérdezése, amíg valamelyik nézete nyitva van (a böngésződ ilyenkor is külön engedélyt kér)',
'admin.plugins.perm.hook:pdf-section-provider': 'Szöveges szakaszok hozzáfűzése az utazás PDF-exportjához',
@@ -453,6 +455,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Rajzol a térképre',
'admin.plugins.cap.routing': 'Útvonaltervezést biztosít',
'admin.plugins.cap.daySchedule': 'Időpontokat ad a tervhez',
+ 'admin.plugins.cap.dayTint': 'Színezi a napokat',
'admin.plugins.cap.geolocation': 'Lekéri a tartózkodási helyedet',
'admin.plugins.cap.events': 'Reagál a tevékenységre',
'admin.plugins.cap.requiresAddon': '{addon} szükséges',
diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts
index 299f6a14a..ca4ff47eb 100644
--- a/shared/src/i18n/id/admin.ts
+++ b/shared/src/i18n/id/admin.ts
@@ -373,6 +373,8 @@ const admin: TranslationStrings = {
'Menawarkan profil routing yang dapat digunakan perencana untuk menghitung rute hari (mis. routing mobil listrik dengan pemberhentian pengisian daya)',
'admin.plugins.perm.hook:day-schedule-provider':
'Melampirkan entri waktu ke rencana hari (pemberhentian pengisian daya, jeda keamanan)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Memberi warna pada hari di rencana hari (misalnya bagian perjalanan mana yang memuat suatu hari)',
'admin.plugins.perm.geolocation:read':
'Meminta lokasi terkinimu selama salah satu tampilannya terbuka (browser-mu tetap akan meminta izin terlebih dahulu)',
'admin.plugins.perm.hook:pdf-section-provider': 'Menambahkan bagian teks ke ekspor PDF perjalanan',
@@ -495,6 +497,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Menggambar di peta',
'admin.plugins.cap.routing': 'Menyediakan routing',
'admin.plugins.cap.daySchedule': 'Menambahkan waktu rencana',
+ 'admin.plugins.cap.dayTint': 'Mewarnai hari',
'admin.plugins.cap.geolocation': 'Membaca lokasimu',
'admin.plugins.cap.events': 'Bereaksi terhadap aktivitas',
'admin.plugins.cap.requiresAddon': 'Membutuhkan {addon}',
diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts
index 70ddfa5f5..723cce44a 100644
--- a/shared/src/i18n/it/admin.ts
+++ b/shared/src/i18n/it/admin.ts
@@ -325,6 +325,8 @@ const admin: TranslationStrings = {
'Offrire profili di routing con cui il pianificatore calcola i giorni (es. routing per auto elettriche con soste di ricarica)',
'admin.plugins.perm.hook:day-schedule-provider':
'Aggiungere voci orarie al piano della giornata (soste di ricarica, margini di sicurezza)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Colorare i giorni nel piano della giornata (ad esempio a quale tratta del viaggio appartiene un giorno)',
'admin.plugins.perm.geolocation:read':
'Richiedere la tua posizione in tempo reale mentre una delle sue viste è aperta (il tuo browser chiederà comunque conferma)',
'admin.plugins.perm.hook:pdf-section-provider': "Aggiungere sezioni di testo all'esportazione PDF del viaggio",
@@ -448,6 +450,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Disegna sulla mappa',
'admin.plugins.cap.routing': 'Offre routing',
'admin.plugins.cap.daySchedule': 'Aggiunge orari al piano',
+ 'admin.plugins.cap.dayTint': 'Colora i giorni',
'admin.plugins.cap.geolocation': 'Legge la tua posizione',
'admin.plugins.cap.events': "Reagisce all'attività",
'admin.plugins.cap.requiresAddon': 'Richiede {addon}',
diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts
index e19384ce6..d1577d8f2 100644
--- a/shared/src/i18n/ja/admin.ts
+++ b/shared/src/i18n/ja/admin.ts
@@ -341,6 +341,7 @@ const admin: TranslationStrings = {
'プランナーが日程のルート計算に使えるルーティングプロファイルを提供(例: 充電スポットを経由する EV ルーティング)',
'admin.plugins.perm.hook:day-schedule-provider':
'日程プランに時間エントリーを追加(充電ストップ、保安検査のバッファ)',
+ 'admin.plugins.perm.hook:day-tint-provider': '日程プランの日付を色分け(例:その日が旅程のどの区間に属するか)',
'admin.plugins.perm.geolocation:read':
'このプラグインのビューが開いている間、あなたの現在地を取得(ブラウザでも改めて確認が求められます)',
'admin.plugins.perm.hook:pdf-section-provider': '旅行の PDF エクスポートにテキストセクションを追加',
@@ -461,6 +462,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': '地図に描画',
'admin.plugins.cap.routing': 'ルーティングを提供',
'admin.plugins.cap.daySchedule': '予定時刻を追加',
+ 'admin.plugins.cap.dayTint': '日を色分け',
'admin.plugins.cap.geolocation': '現在地を読み取り',
'admin.plugins.cap.events': 'アクティビティに反応',
'admin.plugins.cap.requiresAddon': '{addon}が必要',
diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts
index 927638ad5..64c02fa1a 100644
--- a/shared/src/i18n/ko/admin.ts
+++ b/shared/src/i18n/ko/admin.ts
@@ -342,6 +342,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider':
'플래너가 일자 경로 계산에 사용할 라우팅 프로필 제공(예: 충전소 경유 전기차 라우팅)',
'admin.plugins.perm.hook:day-schedule-provider': '일자 계획에 시간 항목 추가(충전 정차, 보안 검색 여유 시간)',
+ 'admin.plugins.perm.hook:day-tint-provider': '일자 계획에서 날짜를 색으로 구분(예: 해당 날짜가 여행의 어느 구간인지)',
'admin.plugins.perm.geolocation:read':
'해당 플러그인 화면이 열려 있는 동안 실시간 위치 요청 (브라우저에서 추가로 확인함)',
'admin.plugins.perm.hook:pdf-section-provider': '여행 PDF 내보내기에 텍스트 섹션 추가',
@@ -461,6 +462,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': '지도에 그리기',
'admin.plugins.cap.routing': '라우팅 제공',
'admin.plugins.cap.daySchedule': '계획 시간 추가',
+ 'admin.plugins.cap.dayTint': '날짜 색상 표시',
'admin.plugins.cap.geolocation': '위치 읽기',
'admin.plugins.cap.events': '활동에 반응',
'admin.plugins.cap.requiresAddon': '{addon} 필요',
diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts
index 40083b475..58c14086d 100644
--- a/shared/src/i18n/nl/admin.ts
+++ b/shared/src/i18n/nl/admin.ts
@@ -324,6 +324,8 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider':
'Routeprofielen aanbieden waarmee de planner dagroutes berekent (bijv. EV-routering met laadstops)',
'admin.plugins.perm.hook:day-schedule-provider': 'Tijdsitems aan het dagplan koppelen (laadstops, buffertijden)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Dagen in het dagplan een kleur geven (bijvoorbeeld bij welk deel van de reis een dag hoort)',
'admin.plugins.perm.geolocation:read':
'Je live locatie opvragen zolang een van zijn weergaven open is (je browser vraagt zelf ook nog om toestemming)',
'admin.plugins.perm.hook:pdf-section-provider': 'Tekstsecties toevoegen aan de PDF-export van de reis',
@@ -447,6 +449,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Tekent op de kaart',
'admin.plugins.cap.routing': 'Biedt routering aan',
'admin.plugins.cap.daySchedule': 'Voegt plantijden toe',
+ 'admin.plugins.cap.dayTint': 'Kleurt dagen',
'admin.plugins.cap.geolocation': 'Leest je locatie',
'admin.plugins.cap.events': 'Reageert op activiteit',
'admin.plugins.cap.requiresAddon': 'Vereist {addon}',
diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts
index 314fe3477..14e07923e 100644
--- a/shared/src/i18n/pl/admin.ts
+++ b/shared/src/i18n/pl/admin.ts
@@ -320,6 +320,8 @@ const admin: TranslationStrings = {
'Udostępnianie profili trasowania, za pomocą których planer wyznacza trasy dni (np. trasowanie aut elektrycznych z postojami na ładowanie)',
'admin.plugins.perm.hook:day-schedule-provider':
'Dołączanie wpisów czasowych do planu dnia (postoje na ładowanie, bufory czasowe)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Oznaczanie dni w planie dnia kolorami (na przykład do którego etapu podróży należy dany dzień)',
'admin.plugins.perm.geolocation:read':
'Odczytywanie Twojej aktualnej lokalizacji, gdy otwarty jest któryś z jego widoków (przeglądarka i tak zapyta o zgodę)',
'admin.plugins.perm.hook:pdf-section-provider': 'Dołączanie sekcji tekstowych do eksportu PDF podróży',
@@ -445,6 +447,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Rysuje na mapie',
'admin.plugins.cap.routing': 'Oferuje trasowanie',
'admin.plugins.cap.daySchedule': 'Uzupełnia czasy planu',
+ 'admin.plugins.cap.dayTint': 'Koloruje dni',
'admin.plugins.cap.geolocation': 'Odczytuje Twoją lokalizację',
'admin.plugins.cap.events': 'Reaguje na aktywność',
'admin.plugins.cap.requiresAddon': 'Wymaga {addon}',
diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts
index de0976dcb..150220998 100644
--- a/shared/src/i18n/ru/admin.ts
+++ b/shared/src/i18n/ru/admin.ts
@@ -325,6 +325,8 @@ const admin: TranslationStrings = {
'Предоставлять профили маршрутизации, по которым планировщик строит маршруты дней (например, маршрутизация для электромобилей с остановками для зарядки)',
'admin.plugins.perm.hook:day-schedule-provider':
'Добавлять записи времени в план дня (остановки для зарядки, запас времени на контроль)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Отмечать дни в плане цветом (например, к какому этапу поездки относится день)',
'admin.plugins.perm.geolocation:read':
'Запрашивать ваше местоположение в реальном времени, пока открыто одно из его представлений (ваш браузер всё равно спросит разрешение)',
'admin.plugins.perm.hook:pdf-section-provider': 'Добавлять текстовые разделы в PDF-экспорт поездки',
@@ -449,6 +451,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Рисует на карте',
'admin.plugins.cap.routing': 'Предоставляет маршрутизацию',
'admin.plugins.cap.daySchedule': 'Добавляет время в план',
+ 'admin.plugins.cap.dayTint': 'Раскрашивает дни',
'admin.plugins.cap.geolocation': 'Считывает ваше местоположение',
'admin.plugins.cap.events': 'Реагирует на активность',
'admin.plugins.cap.requiresAddon': 'Требуется {addon}',
diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts
index 04c77b23f..56db0a7a9 100644
--- a/shared/src/i18n/sv/admin.ts
+++ b/shared/src/i18n/sv/admin.ts
@@ -379,6 +379,8 @@ const admin: TranslationStrings = {
'Erbjuda ruttprofiler som planeraren kan beräkna dagar med (t.ex. elbilsrutter med laddstopp)',
'admin.plugins.perm.hook:day-schedule-provider':
'Lägga till tidsposter i dagsplanen (laddstopp, säkerhetsmarginaler)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Färgkoda dagar i dagsplanen (till exempel vilken etapp av resan en dag tillhör)',
'admin.plugins.perm.geolocation:read':
'Fråga efter din position i realtid medan en av dess vyer är öppen (din webbläsare frågar dessutom först)',
'admin.plugins.perm.hook:pdf-section-provider': 'Lägga till textavsnitt i resans PDF-export',
@@ -503,6 +505,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Ritar på kartan',
'admin.plugins.cap.routing': 'Erbjuder ruttplanering',
'admin.plugins.cap.daySchedule': 'Lägger till tider i planen',
+ 'admin.plugins.cap.dayTint': 'Färgar dagarna',
'admin.plugins.cap.geolocation': 'Läser din position',
'admin.plugins.cap.events': 'Reagerar på aktivitet',
'admin.plugins.cap.requiresAddon': 'Kräver {addon}',
diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts
index 898e18397..2c8e5037c 100644
--- a/shared/src/i18n/tr/admin.ts
+++ b/shared/src/i18n/tr/admin.ts
@@ -369,6 +369,8 @@ const admin: TranslationStrings = {
'Planlayıcının günlerin rotasını hesaplamak için kullanabileceği rota profilleri sunar (ör. şarj duraklı elektrikli araç rotası)',
'admin.plugins.perm.hook:day-schedule-provider':
'Gün planına zaman girişleri ekler (şarj durakları, güvenlik tamponları)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Gün planındaki günleri renklendirir (örneğin bir günün yolculuğun hangi etabına ait olduğu)',
'admin.plugins.perm.geolocation:read':
'Görünümlerinden biri açıkken canlı konumunu sorar (tarayıcın ayrıca izin ister)',
'admin.plugins.perm.hook:pdf-section-provider': 'Seyahat PDF dışa aktarımına metin bölümleri ekler',
@@ -492,6 +494,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Haritada çizim yapar',
'admin.plugins.cap.routing': 'Rota sunar',
'admin.plugins.cap.daySchedule': 'Plana zaman ekler',
+ 'admin.plugins.cap.dayTint': 'Günleri renklendirir',
'admin.plugins.cap.geolocation': 'Konumunu okur',
'admin.plugins.cap.events': 'Etkinliğe tepki verir',
'admin.plugins.cap.requiresAddon': '{addon} gerekir',
diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts
index 32e9af8e3..0dc2eb6f7 100644
--- a/shared/src/i18n/uk/admin.ts
+++ b/shared/src/i18n/uk/admin.ts
@@ -322,6 +322,8 @@ const admin: TranslationStrings = {
'Надавати профілі маршрутизації, за якими планувальник будує маршрути днів (наприклад, маршрути для електромобілів із зупинками для заряджання)',
'admin.plugins.perm.hook:day-schedule-provider':
'Додавати часові записи до плану дня (зупинки для заряджання, буфери часу)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Позначати дні в плані дня кольором (наприклад, до якого етапу подорожі належить день)',
'admin.plugins.perm.geolocation:read':
'Запитувати ваше місцезнаходження в реальному часі, поки відкрито одне з його подань (браузер додатково запитає дозвіл)',
'admin.plugins.perm.hook:pdf-section-provider': 'Додавати текстові розділи до PDF-експорту подорожі',
@@ -446,6 +448,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Малює на карті',
'admin.plugins.cap.routing': 'Пропонує маршрутизацію',
'admin.plugins.cap.daySchedule': 'Додає часи до плану',
+ 'admin.plugins.cap.dayTint': 'Розфарбовує дні',
'admin.plugins.cap.geolocation': 'Читає ваше місцезнаходження',
'admin.plugins.cap.events': 'Реагує на активність',
'admin.plugins.cap.requiresAddon': 'Потрібен {addon}',
diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts
index 12fc0e797..a3bb4f887 100644
--- a/shared/src/i18n/vi/admin.ts
+++ b/shared/src/i18n/vi/admin.ts
@@ -322,6 +322,8 @@ const admin: TranslationStrings = {
'Cung cấp các hồ sơ định tuyến để trình lập kế hoạch tính lộ trình cho các ngày (vd. định tuyến xe điện với điểm dừng sạc)',
'admin.plugins.perm.hook:day-schedule-provider':
'Gắn các mục thời gian vào kế hoạch ngày (điểm dừng sạc, thời gian đệm an ninh)',
+ 'admin.plugins.perm.hook:day-tint-provider':
+ 'Tô màu các ngày trong kế hoạch ngày (ví dụ ngày đó thuộc chặng nào của chuyến đi)',
'admin.plugins.perm.geolocation:read':
'Hỏi vị trí trực tiếp của bạn khi một trong các chế độ xem của nó đang mở (trình duyệt của bạn vẫn sẽ hỏi trước)',
'admin.plugins.perm.hook:pdf-section-provider': 'Thêm các phần văn bản vào bản xuất PDF của chuyến đi',
@@ -445,6 +447,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': 'Vẽ trên bản đồ',
'admin.plugins.cap.routing': 'Cung cấp định tuyến',
'admin.plugins.cap.daySchedule': 'Thêm giờ kế hoạch',
+ 'admin.plugins.cap.dayTint': 'Tô màu ngày',
'admin.plugins.cap.geolocation': 'Đọc vị trí của bạn',
'admin.plugins.cap.events': 'Phản hồi hoạt động',
'admin.plugins.cap.requiresAddon': 'Cần {addon}',
diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts
index 148e35acc..7456e1c6e 100644
--- a/shared/src/i18n/zh-TW/admin.ts
+++ b/shared/src/i18n/zh-TW/admin.ts
@@ -329,6 +329,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider':
'提供路線規劃設定檔,供規劃工具計算每日路線(例如含充電停靠站的電動車路線規劃)',
'admin.plugins.perm.hook:day-schedule-provider': '向每日行程附加時間項目(充電停靠站、緩衝時間)',
+ 'admin.plugins.perm.hook:day-tint-provider': '為每日行程中的日期標示顏色(例如某天屬於旅程的哪一段)',
'admin.plugins.perm.geolocation:read': '在其任一檢視開啟時查詢你的即時位置(你的瀏覽器仍會先詢問)',
'admin.plugins.perm.hook:pdf-section-provider': '向行程 PDF 匯出附加文字段落',
'admin.plugins.perm.hook:atlas-layer-provider': '在 Atlas 世界地圖上標示國家(例如願望清單或旅遊警示)',
@@ -443,6 +444,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': '在地圖上繪製',
'admin.plugins.cap.routing': '提供路線規劃',
'admin.plugins.cap.daySchedule': '新增行程時間',
+ 'admin.plugins.cap.dayTint': '為日期著色',
'admin.plugins.cap.geolocation': '讀取你的位置',
'admin.plugins.cap.events': '回應活動',
'admin.plugins.cap.requiresAddon': '需要 {addon}',
diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts
index a883a17cc..848b8644f 100644
--- a/shared/src/i18n/zh/admin.ts
+++ b/shared/src/i18n/zh/admin.ts
@@ -287,6 +287,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:map-layer-provider': '在旅行地图上绘制路线、走廊和区域',
'admin.plugins.perm.hook:route-provider': '提供路线规划方案,供规划器计算每日路线(例如带充电停靠点的电动车路线)',
'admin.plugins.perm.hook:day-schedule-provider': '向当日计划添加时间条目(充电停靠点、安全缓冲时间)',
+ 'admin.plugins.perm.hook:day-tint-provider': '为当日计划中的日期标注颜色(例如某天属于行程的哪一段)',
'admin.plugins.perm.geolocation:read': '在其某个视图打开时请求你的实时位置(你的浏览器仍会先询问)',
'admin.plugins.perm.hook:pdf-section-provider': '向行程 PDF 导出追加文本段落',
'admin.plugins.perm.hook:atlas-layer-provider': '在 Atlas 世界地图上高亮国家(例如愿望清单或旅行提醒)',
@@ -403,6 +404,7 @@ const admin: TranslationStrings = {
'admin.plugins.cap.mapLayers': '在地图上绘制',
'admin.plugins.cap.routing': '提供路线规划',
'admin.plugins.cap.daySchedule': '补充计划时间',
+ 'admin.plugins.cap.dayTint': '为日期着色',
'admin.plugins.cap.geolocation': '读取你的位置',
'admin.plugins.cap.events': '响应活动',
'admin.plugins.cap.requiresAddon': '需要 {addon}',
diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md
index 65edc135d..ae4612c99 100644
--- a/wiki/Plugin-Cookbook.md
+++ b/wiki/Plugin-Cookbook.md
@@ -481,6 +481,65 @@ dayScheduleProvider: {
},
```
+### Colour-code the days of a multi-destination trip
+
+`hook:day-schedule-provider` can announce "Kanazawa begins" as a row, but it cannot make
+day 12 *look* like it belongs to Kanazawa. `hook:day-tint-provider` does — it colours a
+day card in the Plan sidebar (and that day's mobile chip) with one of the four tones:
+
+```js
+dayTintProvider: {
+ async getDayTints(tripId, ctx) {
+ const legs = await loadLegs(ctx, tripId) // your own db:own rows
+ const days = await ctx.trips.getDays(tripId)
+ return days
+ .map((d) => ({ d, leg: legs.find((l) => covers(l, d)) }))
+ .filter(({ leg }) => leg) // days no leg covers stay untinted
+ .map(({ d, leg }) => ({ dayId: d.id, tone: leg.tone, label: leg.name }))
+ },
+},
+```
+
+Return **one entry per day** — the bound here is the trip's day count, not a fixed item
+cap, so this stays correct on a six-month itinerary where the ≤60-item day-schedule hook
+would tint only the first 60 days.
+
+#### Tint one region instead of the whole card
+
+A day card has three separately tintable regions, and `tone` above is just the shorthand
+that fills all of them. Name them individually when colouring the whole card would fight
+the content — a packed day is much easier to read with only its badge marked:
+
+```js
+// Bold on the badge (and the mobile day chip), plain everywhere else.
+return days.map((d) => ({ dayId: d.id, badgeTone: legTone(d), label: legName(d) }))
+
+// Or: a quiet leg colour on the card, with the activity list left completely alone.
+return days.map((d) => ({
+ dayId: d.id,
+ badgeTone: legTone(d),
+ headerTone: legTone(d),
+ label: legName(d),
+}))
+
+// Or: reserve the activity list for a per-day signal of your own.
+return days.map((d) => ({ dayId: d.id, headerTone: legTone(d), activityTone: overbooked(d) ? 'danger' : undefined }))
+```
+
+| Field | Region |
+|---|---|
+| `badgeTone` | the day-number badge — smallest and boldest; also the mobile day chip |
+| `headerTone` | the day header row (number, title, date, cost); hovering deepens the tint |
+| `activityTone` | the expanded activity list — largest, behind the densest text, tinted faintest |
+
+A region you never name is not tinted and renders exactly as it does without your plugin.
+
+Give the host a tone, never a hex: it picks the alpha per theme *and* per region, so the
+tint stays subtle in both light and dark. `label` becomes the day's tooltip. A day takes
+at most one contribution and it is resolved **whole** — within your list the first entry
+for a day wins, and if another plugin also tints that day the first granted provider
+wins outright, so days never flicker and two plugins can't each own part of one card.
+
---
## Honour account deletion and data export (GDPR)
diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md
index 54ea4507a..5183928e9 100644
--- a/wiki/Plugin-Development.md
+++ b/wiki/Plugin-Development.md
@@ -668,6 +668,7 @@ module.exports = definePlugin({
| `mapLayerProvider.getLayers(tripId, ctx)` → `MapLayerContribution[]` | `hook:map-layer-provider` | **live** — bounded vector overlays on the trip map: a computed route, a reachable-range corridor, a zone. Each layer is `{id, name?, features}` with features `{type: 'polyline'\|'polygon'\|'circle', points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. Styling stays in the tone palette; width (1–8), opacity (0.05–1) and radius (≤2000 km) are clamped, `dash` is an enum. Budgets per plugin: ≤4 layers, ≤150 features, ≤8000 vertices, ≤2000 vertices/shape — an oversized or partly-invalid shape is dropped whole, never truncated. Declarative only; drawn beneath TREK's own day route on both the Leaflet and GL renderers. Also `GET /api/map-layers/:tripId` |
| `routeProvider.getRoute(request, ctx)` → `RouteProviderResult` | `hook:route-provider` | **live** — routes a day's stops under one of the plugin's declared `capabilities.routeProfiles` (an EV profile with charging stops, a scenic profile…). `request` is `{tripId, dayId, profile, waypoints}` (2–30 located stops in visit order); the result is `{coordinates, distance, duration, legs, viaPoints?}` and is validated **whole**: ≤10000 vertices, `legs` must be exactly `waypoints−1` entries (each `{distance, duration, note?}` — `note` ≤120 chars shows on the sidebar connector), ≤40 via points (`{lat, lng, label?, tone?, dwellSeconds?}` drawn as stops on the route line). A malformed result is discarded and the planner falls back to straight lines. **Targeted, not a fan-out**: TREK calls exactly the provider whose profile the user picked in the route toggle, with a 20 s timeout (room to call an external solver through your declared egress). `POST /api/plugin-routes/:pluginId/:profileId` |
| `dayScheduleProvider.getSchedule(tripId, ctx)` → `DayScheduleContribution[]` | `hook:day-schedule-provider` | **live** — time contributions rendered into the day plan on desktop and mobile: "35 min charging at this stop", "45 min security before this flight". Each is `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}` — anchored under a place/booking row or at a day's start/end (default end). `dayId` must belong to the trip (checked server-side), `minutes` (1–1440) is shown on the row **and folded into the day's route-footer total**, `label` ≤120 chars, ≤60 items/plugin. Also `GET /api/day-schedule/:tripId` |
+| `dayTintProvider.getDayTints(tripId, ctx)` → `DayTintContribution[]` | `hook:day-tint-provider` | **live** — colours painted into a 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. Each is `{dayId, tone?, badgeTone?, headerTone?, activityTone?, label?}`. The card has **three separately tintable regions** — the day-number badge (also the mobile chip), 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 you do not name, and an unnamed region renders exactly as it does with no plugin. Tone only, never a hex: the host picks the alpha per theme **and** per region, so the tint stays subtle in light and dark and a tinted header hovers to a deeper mix of its own tone. `dayId` must belong to the trip (checked server-side), `label` ≤ 60 chars becomes the day's tooltip, raw array sliced at 2000. **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 colours, and a losing plugin cannot fill in the winner's untinted regions. Bounded by the trip's day count rather than a fixed item cap, which is why this is not `dayScheduleProvider` (≤60 items would tint only the first 60 days of a long trip). Also `GET /api/day-tints/:tripId` |
| `pdfSectionProvider.getSections(tripId, ctx)` → `PdfSection[]` | `hook:pdf-section-provider` | **live** — text-only sections appended to the trip PDF export. Each is `{title, paragraphs?, table?}`; the host escapes and lays everything out itself (no markup ever reaches the document), caps counts (≤5 sections/plugin, ≤20 paragraphs, ≤8 headers, ≤50 rows) + lengths (title 120, paragraph 2000, header 60, cell 200) and clips rows to the header width. Also `GET /api/pdf-sections/:tripId` |
| `atlasLayerProvider.getLayers(ctx)` → `AtlasLayer[]` | `hook:atlas-layer-provider` | **live** — country tint layers drawn over the Atlas world map (wishlists, advisories, …). **User-scoped**: the host binds the acting user, the hook takes no target parameter. Each layer is `{id, name?, countries: [{code, tone?, label?}]}`; codes must be ISO-3166 alpha-2 (uppercase-coerced), tone is enum-whitelisted, counts capped (≤3 layers/plugin, ≤300 countries/layer). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/atlas-layers` |
| `journalEntryProvider.getRows(entryId, ctx)` → `{ label, value?, url? }[]` | `hook:journal-entry-provider` | **live** — extra rows rendered under a journal entry card (needs the Journey addon; the entry's journey is access-checked like the journal detail routes). Same hardening as place details plus server-side normalization: ≤12 rows/plugin, label ≤60, value ≤200, url http/https/mailto-only. Also `GET /api/journal-entry-rows/:entryId` |
@@ -1185,6 +1186,7 @@ guard optional `ctx.*` namespaces.
| `hook:map-layer-provider` | `hooks.mapLayerProvider` — bounded vector overlays (routes, corridors, zones) on the trip map |
| `hook:route-provider` | `hooks.routeProvider` — routing profiles for the planner's route toggle (declared in `capabilities.routeProfiles`) |
| `hook:day-schedule-provider` | `hooks.dayScheduleProvider` — time contributions in the day plan (charging, buffers), totalled in the route footer |
+| `hook:day-tint-provider` | `hooks.dayTintProvider` — colours for a day card's badge / header / activity list in the Plan sidebar (e.g. which leg of the trip a day belongs to). One contribution per day: first granted provider wins |
| `hook:pdf-section-provider` | `hooks.pdfSectionProvider` — sections appended to the trip PDF export |
| `hook:atlas-layer-provider` | `hooks.atlasLayerProvider` — per-user country tint layers on the Atlas map |
| `hook:journal-entry-provider` | `hooks.journalEntryProvider` — extra rows on a journal entry card |
diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md
index 7dedbf8fd..fc3fe0a81 100644
--- a/wiki/Plugin-Permissions.md
+++ b/wiki/Plugin-Permissions.md
@@ -63,6 +63,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. |
| `hook:route-provider` | Offer **routing profiles** the planner's route toggle can route days with, via the `hooks.routeProvider` provider hook | Implement `RouteProvider` in `hooks` and declare the profiles in `capabilities.routeProfiles` (max 3; each `{id, label, icon?}`). TREK calls `getRoute({tripId, dayId, profile, waypoints})` for exactly the profile the user picked — this is **targeted**, not a fan-out — with a 20 s timeout so the plugin can call an external solver through its declared egress. The result (`coordinates`, `distance`, `duration`, `legs`, `viaPoints?`) is validated whole (leg count must equal `waypoints−1`, ≤10000 vertices, ≤40 vias); anything malformed is discarded and the planner falls back to straight lines. Exposed at `POST /api/plugin-routes/:pluginId/:profileId` (member-gated per trip). |
| `hook:day-schedule-provider` | Attach **time contributions** to the day plan via the `hooks.dayScheduleProvider` provider hook | Implement `DayScheduleProvider` in `hooks` — returns `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}[]`. Rows render under their anchor (a place/booking row, or a day's start/end) on desktop and mobile, and `minutes` is **folded into the day's route-footer total** — the one hook whose output feeds displayed timing, which is why minutes are clamped (1–1440) and `dayId` is checked against the trip's own days. ≤60 items per plugin, labels sanitized + capped (≤120); a failing provider is skipped. Exposed at `GET /api/day-schedule/:tripId`. |
+| `hook:day-tint-provider` | Colour-code **whole days** in the day plan via the `hooks.dayTintProvider` provider hook | Implement `DayTintProvider` in `hooks` — returns `{dayId, tone?, badgeTone?, headerTone?, activityTone?, label?}[]`, one entry per day you want coloured. A day card has **three separately tintable regions** — the day-number badge (which is also the mobile day chip), the header row, and the expanded activity list — so you can mark a leg boldly on the badge and leave the dense activity list plain. `tone` is a shorthand for every region you do not name; a region you never name is not tinted at all and renders exactly as it does without your plugin. **Tone only, never a hex** — a fixed pastel would break the dark theme; the host chooses the alpha per theme and per region (a large surface behind dense text gets a fainter tint than a small badge), and a tinted header hovers to a deeper mix of its own tone instead of losing its colour. `dayId` is checked against the trip's own days, `label` (≤60) becomes the day's tooltip, the raw array is sliced at 2000. **A day takes at most one contribution, and it is resolved whole**: within one provider's list the first entry for a day wins, and across plugins the first granted provider wins — so a day can't flicker between colours, and a losing plugin can't fill in the winner's untinted regions. Unlike `hook:day-schedule-provider` there is no fixed item cap; the bound is the trip's day count, which is what makes this usable on a six-month itinerary. A failing provider is skipped. Exposed at `GET /api/day-tints/:tripId`. |
| `geolocation:read` | Ask the HOST for the browser's live position from the plugin's sandboxed frames (`window.trek.geolocation`) | A **bridge-level** permission — it unlocks no ctx RPC method. The sandbox itself never gains the geolocation API: the host page reads `navigator.geolocation` (the browser's own site permission prompt still applies) and posts plain `{lat, lng, accuracy, heading, speed, timestamp}` data into the frame over postMessage. `get()` resolves one fix; `watch(cb)` streams updates until unsubscribed, and the host force-stops the GPS watch when the frame closes. Nothing is sent to the server — a position only reaches the plugin's server code if its own client ships it through one of its routes (visible in that route's traffic like any other call). |
| `hook:table-contributor` | Contribute host-rendered **columns/actions** into a native planner view via the `hooks.tableContributor` provider hook | Implement `TableContributor` in `hooks` — returns declarative column/action leaves keyed by `entityId` (never markup). The host normalizes + bounds every field (length caps, `http`/`https`/`mailto`-only urls, enum tone/target) and renders them in the reservations, places, day, costs, packing and files views; an action opens your sandboxed frame or calls one of your routes. Also exposed at `GET /api/view-contributions/:view/:tripId`. |
| `hook:pdf-section-provider` | Append text-only **sections** to the trip PDF export via the `hooks.pdfSectionProvider` provider hook | Implement `PdfSectionProvider` in `hooks` — returns `{title, paragraphs?, table?}[]` of plain strings the export escapes and lays out itself; no markup ever reaches the document. The host caps the section/paragraph/header/row counts (≤5/≤20/≤8/≤50) and every string length, and clips table rows to the header width; a failing provider is skipped. Exposed at `GET /api/pdf-sections/:tripId`. |