From c89d2bf3a1faf3dda8e8daa2e2e04ef7e881545c Mon Sep 17 00:00:00 2001 From: Renzo Beux Date: Wed, 29 Jul 2026 12:05:31 -0300 Subject: [PATCH] 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. --- client/src/api/client.ts | 19 ++ .../components/Admin/AdminPluginsPanel.tsx | 5 +- .../src/components/Planner/DayPlanSidebar.tsx | 37 +++- .../components/Plugins/PluginDaySchedule.tsx | 56 +++++- client/src/index.css | 17 ++ client/src/mobile/mobile.css | 6 + .../screens/admin/MAdminPluginsPanel.tsx | 5 +- client/src/mobile/screens/trip/MTripShell.tsx | 11 ++ plugin-sdk/src/cli/ui.ts | 1 + plugin-sdk/src/index.ts | 37 ++++ plugin-sdk/src/manifest.ts | 2 +- plugin-sdk/src/permissions.ts | 1 + plugin-sdk/test/sdk.test.ts | 4 +- .../src/nest/plugins/day-tints.controller.ts | 142 +++++++++++++++ server/src/nest/plugins/plugins.module.ts | 3 +- server/src/nest/plugins/protocol/envelope.ts | 1 + server/src/nest/plugins/runtime/plugin-sdk.ts | 22 +++ .../plugins/supervisor/plugin-supervisor.ts | 1 + .../unit/plugins/day-tints.controller.test.ts | 170 ++++++++++++++++++ .../unit/plugins/provider-hook-grant.test.ts | 14 ++ shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/ja/admin.ts | 2 + shared/src/i18n/ko/admin.ts | 2 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/zh-TW/admin.ts | 2 + shared/src/i18n/zh/admin.ts | 2 + wiki/Plugin-Cookbook.md | 59 ++++++ wiki/Plugin-Development.md | 2 + wiki/Plugin-Permissions.md | 1 + 46 files changed, 664 insertions(+), 17 deletions(-) create mode 100644 server/src/nest/plugins/day-tints.controller.ts create mode 100644 server/tests/unit/plugins/day-tints.controller.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 8d355bf48..f8bad6dcf 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -687,6 +687,21 @@ export interface PluginDayScheduleItem { tone: 'default' | 'success' | 'warn' | 'danger'; } +/** The colours a dayTintProvider plugin puts into one day card, so leg membership is + * visible while scrolling the itinerary. The card has three separately tintable + * regions; an absent one is not tinted and renders exactly as it does with no plugin. + * Server-normalized: dayIds checked against the trip, one contribution per day (first + * granted provider wins), the `tone` shorthand already resolved into the regions, + * labels sanitized + capped. Tone only — the client picks the alpha per theme. */ +export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger' +export interface PluginDayTint { + pluginId: string; dayId: number; + badgeTone?: PluginDayTintTone; + headerTone?: PluginDayTintTone; + activityTone?: PluginDayTintTone; + label?: string; +} + /** A route computed by a routeProvider plugin (server-normalized: coordinates * range-checked, legs forced to waypoints-1, vias capped). null = provider failed * or refused — the caller falls back to straight lines like on an OSRM outage. */ @@ -754,6 +769,10 @@ export const pluginsApi = { // hook (charging stops, security buffers). Host-normalized; fail-safe. daySchedule: (tripId: number | string) => apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }), + // Per-day colours plugins put behind the day cards via the dayTintProvider hook + // (which leg of the trip a day belongs to). Host-normalized; fail-safe. + dayTints: (tripId: number | string) => + apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }), // Text-only sections plugins append to the trip PDF export via the // pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe. pdfSections: (tripId: number | string) => diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index de5d42c54..cb641941b 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -5,7 +5,7 @@ import { ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, - Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' @@ -192,7 +192,7 @@ const PERM_KEYS = [ 'events:subscribe', 'jobs:run', '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', 'geolocation:read', + 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read', 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound', ] @@ -247,6 +247,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') }) + if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') }) if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 3566e83c5..c1941f37a 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -34,7 +34,7 @@ import { useDayNotes } from '../../hooks/useDayNotes' import { useExchangeRates } from '../../hooks/useExchangeRates' import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants' import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector' -import { usePluginDaySchedule, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule' +import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal' @@ -1051,6 +1051,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const activePlugins = usePluginStore(s => s.plugins) // Plugin time contributions in the day plan (dayScheduleProvider hook). const daySchedule = usePluginDaySchedule(S.tripId) + // Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a + // day belongs to. Empty unless a granted plugin provides them. + const dayTints = usePluginDayTints(S.tripId) const routeProfileOptions = useMemo(() => { const opts: Array<{ key: string; label: string }> = [ { key: 'driving', label: 'Driving' }, @@ -1317,9 +1320,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const merged = mergedItemsMap[day.id] || [] const dayNoteUi = noteUi[day.id] const placeItems = merged.filter(i => i.type === 'place') + const dayTint = dayTints[day.id] + // Resolved once per day: the header owns a background that its hover + // handlers reassign imperatively, so both the base and the hover value have + // to be tint-aware or the first hover-out would wipe the colour. + const headerTintBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header') ?? 'transparent' + const headerTintHoverBg = dayTintBackground(dayTint?.headerTone, '--day-tint-header-hover') ?? 'var(--bg-tertiary)' return ( -
+ // The card wrapper stays untinted — its three regions (badge, header, + // activity list) paint themselves, so a plugin controls them separately. +
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
{ if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} - onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} + // A tinted header hovers to a deeper mix of its own tone rather than to + // the neutral --bg-tertiary, so hovering reads as a state change on + // this day instead of momentarily losing its colour. Selection keeps + // winning outright, so a selected day is never ambiguous. + onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = headerTintHoverBg }} + onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : headerTintBg }} > {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} {(() => { @@ -1356,8 +1371,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP flexShrink: 0, alignSelf: 'flex-start', width: hasWeather ? 34 : 26, borderRadius: hasWeather ? 11 : '50%', - background: isSelected ? 'var(--accent)' : 'var(--bg-hover)', - color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)', + // Selection still wins. A tinted badge mixes the tone INTO + // --bg-hover so it stays the same pill component, and takes + // --text-secondary: the number is 11px bold, so 4.5:1 applies + // and --text-muted is already borderline on the untinted pill. + background: isSelected ? 'var(--accent)' : (dayTintBackground(dayTint?.badgeTone, '--day-tint-badge', 'var(--bg-hover)') ?? 'var(--bg-hover)'), + color: isSelected ? 'var(--accent-text)' : (dayTint?.badgeTone ? 'var(--text-secondary)' : 'var(--text-muted)'), display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden', }}>
@@ -1479,7 +1498,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {/* Aufgeklappte Orte + Notizen */} {isExpanded && (
{ e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() diff --git a/client/src/components/Plugins/PluginDaySchedule.tsx b/client/src/components/Plugins/PluginDaySchedule.tsx index da4240423..6187f15dd 100644 --- a/client/src/components/Plugins/PluginDaySchedule.tsx +++ b/client/src/components/Plugins/PluginDaySchedule.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Zap } from 'lucide-react' -import { pluginsApi, type PluginDayScheduleItem } from '../../api/client' +import { pluginsApi, type PluginDayScheduleItem, type PluginDayTint, type PluginDayTintTone } from '../../api/client' /** * Host-rendered rows for the `dayScheduleProvider` plugin hook — time @@ -62,6 +62,60 @@ export function usePluginDaySchedule(tripId?: number | string | null): PluginDay }, [items]) } +/** dayId → the per-region tones (and optional tooltip) for that day's card. */ +export type PluginDayTints = Record> + +const EMPTY_TINTS: PluginDayTints = {} + +/** + * Host-rendered day colours for the `dayTintProvider` plugin hook — "day 12 belongs + * to the Kanazawa leg". A sibling of usePluginDaySchedule in every respect: same + * fail-safe fetch, same vetted-data-only contract. Separate from the schedule hook + * because its output is bounded by the trip's day count rather than the schedule + * hook's ≤60 items, which a long multi-destination trip would blow straight past. + * + * The server has already resolved precedence (one tint per day, first granted + * provider wins), so this is a flat index — the callers just look a day up. + */ +export function usePluginDayTints(tripId?: number | string | null): PluginDayTints { + const [tints, setTints] = useState([]) + + useEffect(() => { + if (tripId == null) { setTints([]); return } + let alive = true + pluginsApi.dayTints(tripId) + .then(r => { if (alive) setTints(r.tints || []) }) + .catch(() => { if (alive) setTints([]) }) // fail-safe: no tints + return () => { alive = false } + }, [tripId]) + + return useMemo(() => { + if (tints.length === 0) return EMPTY_TINTS + const out: PluginDayTints = {} + for (const t of tints) { + const { pluginId: _pluginId, dayId: _dayId, ...regions } = t + out[t.dayId] = regions + } + return out + }, [tints]) +} + +/** The `color-mix` background for one tinted region, or undefined when that region has + * no tone — in which case the caller keeps whatever it renders without plugins. + * + * `alphaVar` names a CSS custom property holding the strength, which varies per theme + * AND per region: the tones are fixed hexes, so one alpha cannot serve both themes + * (#4F46E5 is itself a dark colour and vanishes on a dark surface at a light theme's + * alpha), and a large region behind dense text needs a fainter tint than a small badge. */ +export function dayTintBackground( + tone: PluginDayTintTone | undefined, + alphaVar: string, + base = 'transparent', +): string | undefined { + if (!tone) return undefined + return `color-mix(in srgb, ${TONE_COLORS[tone] ?? TONE_COLORS.default} var(${alphaVar}), ${base})` +} + export function formatScheduleMinutes(minutes: number): string { const h = Math.floor(minutes / 60) const m = minutes % 60 diff --git a/client/src/index.css b/client/src/index.css index be779a914..af821e157 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -529,6 +529,18 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --accent-hover: #1f2937; /* hover state of an accent fill */ --accent-subtle: #f1f5f9; /* faint accent-tinted surface (chips, selected) */ + /* Strength of a dayTintProvider plugin's colour in each region of a day card. + Per-theme because the tones are fixed hexes: the default tone #4F46E5 is itself a + dark colour, so a light theme's alpha makes it vanish against the dark sidebar, + while warn #f59e0b at the dark alpha would outshine the selected day. Per-region + because area and text density differ — the badge is a small bold pill, the + activity list is a large surface behind dense rows. Tuned so every region reads + above the hover step and below the selected one. */ + --day-tint-badge: 16%; + --day-tint-header: 8%; + --day-tint-header-hover: 14%; + --day-tint-activity: 6%; + --fs-scale-title: 1; --fs-scale-subtitle: 1; --fs-scale-body: 1; @@ -620,6 +632,11 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --accent-hover: #d4d4d8; --accent-subtle: rgba(255, 255, 255, 0.08); + --day-tint-badge: 24%; + --day-tint-header: 13%; + --day-tint-header-hover: 20%; + --day-tint-activity: 10%; + --success: #22c55e; --success-soft: rgba(34, 197, 94, 0.15); --danger: #ef4444; --danger-soft: rgba(239, 68, 68, 0.15); --warning: #f59e0b; --warning-soft: rgba(245, 158, 11, 0.15); diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index 3ed4be622..4b2dcf9ae 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -32,6 +32,11 @@ --m-mkfg: #fff; --m-trackoff: rgba(16,16,19,.14); + /* Strength of a dayTintProvider plugin's colour on an inactive day chip. A notch + * above the desktop badge: the chip has no neutral base under it, so a softer tint + * reads as a rendering artifact rather than a deliberate colour. */ + --day-tint-chip: 20%; + /* Toggle knob is white on both themes (off-track is translucent enough). */ --m-knob: #fff; @@ -78,6 +83,7 @@ --m-mkr: #F5F5F7; --m-mkfg: #101013; --m-trackoff: rgba(255,255,255,.14); + --day-tint-chip: 28%; } /* Geist "subtext" tier weight — single knob. The design's Geist labels/numbers diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index bee362cb0..fe3b28efa 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -4,7 +4,7 @@ import { ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, - Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../../../components/shared/PluginIcon' import { adminApi } from '../../../api/client' @@ -192,7 +192,7 @@ const PERM_KEYS = [ 'events:subscribe', 'jobs:run', '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', 'geolocation:read', + 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read', 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound', ] @@ -247,6 +247,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') }) + if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') }) if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 89755982d..b2d42eb50 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -12,6 +12,7 @@ import MPlacesBrowser from './places/MPlacesBrowser' import MTripTabPanel from './tabs/MTripTabPanel' import MTripSheets from './sheets/MTripSheets' import MTripLoadingSplash from './MTripLoadingSplash' +import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule' import type { Day } from '../../../types' /** @@ -176,6 +177,10 @@ export default function MTripShell({ const planner = useTripPlanner() const { t, language, tripId, days, trip, navigate, packingItems, todoItems } = planner + // Per-day colours from the dayTintProvider plugin hook — the mobile counterpart + // of the desktop day-card wash, carried on the day chips. Empty without a plugin. + const dayTints = usePluginDayTints(tripId) + const [view, setView] = useState('plan') const [mode, setMode] = useState('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 (