Add a dayTintProvider plugin hook

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

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

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

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
This commit is contained in:
Renzo Beux
2026-07-29 12:05:31 -03:00
committed by Maurice
parent 28ffc814c0
commit c89d2bf3a1
46 changed files with 664 additions and 17 deletions
+19
View File
@@ -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) =>
@@ -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') })
@@ -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 (
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
// The card wrapper stays untinted — its three regions (badge, header,
// activity list) paint themselves, so a plugin controls them separately.
<div key={day.id} title={dayTint?.label || undefined} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
className="dp-day-header"
@@ -1332,7 +1343,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px',
cursor: 'pointer',
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'),
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : headerTintBg),
transition: 'background 0.12s',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
@@ -1340,8 +1351,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
borderRadius: isDragTarget ? 8 : 0,
touchAction: 'manipulation',
}}
onMouseEnter={e => { 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',
}}>
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>
@@ -1479,7 +1498,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Aufgeklappte Orte + Notizen */}
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
// The activity list — the largest region and the one behind the
// densest text, so its tint is the faintest of the three.
style={{ background: dayTintBackground(dayTint?.activityTone, '--day-tint-activity', 'var(--bg-hover)') ?? 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
@@ -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<number, Omit<PluginDayTint, 'pluginId' | 'dayId'>>
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<PluginDayTint[]>([])
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
+17
View File
@@ -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);
+6
View File
@@ -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
@@ -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') })
@@ -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<MTripView>('plan')
const [mode, setMode] = useState<MTripMode>('go')
const [browseFromEdit, setBrowseFromEdit] = useState(false)
@@ -300,12 +305,18 @@ export default function MTripShell({
<div className="flex flex-1 items-center gap-[2px] overflow-x-auto rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
{days.map((day, idx) => {
const active = day.id === planner.selectedDayId
const tint = dayTints[day.id]
return (
<button
key={day.id}
type="button"
onClick={() => 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'
}`}