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
@@ -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