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'
}`}
+1
View File
@@ -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' },
+37
View File
@@ -585,6 +585,42 @@ export interface DayScheduleProvider {
getSchedule(tripId: number, ctx: PluginContext): Promise<DayScheduleContribution[]>;
}
/** A colour the host paints into one day card in the Plan sidebar (and into that day's
* mobile chip) — so a trip split into legs shows its leg membership while you scroll
* the itinerary. Tone only, never a hex: the host picks the alpha per theme AND per
* region, 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<DayTintContribution[]>;
}
/** A text-only section the host appends to a trip's PDF export. Declarative only —
* plain strings the host lays out and escapes; no markup ever reaches the document. */
export interface PdfSection {
@@ -703,6 +739,7 @@ export interface PluginDefinition {
mapLayerProvider?: MapLayerProvider;
routeProvider?: RouteProvider;
dayScheduleProvider?: DayScheduleProvider;
dayTintProvider?: DayTintProvider;
pdfSectionProvider?: PdfSectionProvider;
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
+1 -1
View File
@@ -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',
+1
View File
@@ -28,6 +28,7 @@ export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
mapLayerProvider: 'hook:map-layer-provider',
routeProvider: 'hook:route-provider',
dayScheduleProvider: 'hook:day-schedule-provider',
dayTintProvider: 'hook:day-tint-provider',
pdfSectionProvider: 'hook:pdf-section-provider',
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',
+2 -2
View File
@@ -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);
});
@@ -0,0 +1,142 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../database/database.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
import { stripEmoji } from './text-sanitize';
/**
* GET /api/day-tints/:tripId — the colours the planner paints into a day card in the
* Plan sidebar (and into the mobile day chips) via the `dayTintProvider` hook. A trip
* split into colour-coded legs — Tokyo, Kanazawa, Kyoto — becomes readable at a
* glance while scrolling the itinerary.
*
* The card exposes three separately tintable regions — the day-number badge, the
* header row, and the expanded activity list — so a plugin can mark the leg boldly on
* the badge while leaving the dense activity list plain. `tone` is the shorthand that
* fills every region a contribution does not name.
*
* Why this is its own hook rather than one dayScheduleProvider item per day: that
* hook is capped at MAX_ITEMS = 60 per provider, so a six-month trip would tint its
* first 60 days and silently stop — failing exactly the long multi-destination trips
* this is for. Here the natural bound is the trip's own day count, because a day
* gets at most one tint.
*
* Same contract as the other provider hooks: additive + fail-safe, declarative data
* only, strings sanitized + capped. dayIds are checked against the trip so a
* stale/hand-edited contribution can never tint another trip's day.
*/
type Tone = 'default' | 'success' | 'warn' | 'danger';
export interface DayTint {
pluginId: string;
dayId: number;
/** Per-region tones, already resolved against the contribution's `tone` shorthand.
* An absent region is not tinted and renders exactly as it does without plugins. */
badgeTone?: Tone;
headerTone?: Tone;
activityTone?: Tone;
/** Optional tooltip on the tinted day ("Kanazawa leg"). */
label?: string;
}
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
// Bound the work, not just the output: an all-invalid raw array (no entry ever
// reaching `out`) would otherwise be iterated in full. Slice up front, well above
// any legitimate payload — a trip has one tint per day, and 2000 days is 5½ years.
const MAX_RAW_TINTS = 2000;
/**
* One region's tone. `undefined` means the region was not named, so the caller falls
* back to the shorthand; naming a region with a bogus value still tints it (as
* `default`) rather than silently doing nothing, because the plugin clearly asked for
* a tint there — same "unknown enum degrades, never fails the item" rule the other
* hooks use for `tone`.
*/
const region = (v: unknown): Tone | undefined =>
v === undefined || v === null ? undefined : (TONES.has(v as string) ? (v as Tone) : 'default');
function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unknown): DayTint[] {
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>).slice(0, MAX_RAW_TINTS) : [];
const out: DayTint[] = [];
// One tint per day per provider — a plugin that contradicts itself gets its first
// answer taken, not its last, so the day never depends on array order downstream.
const seen = new Set<number>();
for (const it of list) {
if (!it || typeof it !== 'object') continue;
const dayId = Number(it.dayId);
// A day outside this trip, or one already tinted by this provider — drop it.
if (!Number.isInteger(dayId) || !tripDayIds.has(dayId) || seen.has(dayId)) continue;
seen.add(dayId);
const label = cap(it.label, 60);
const shorthand = region(it.tone);
let badgeTone = region(it.badgeTone) ?? shorthand;
let headerTone = region(it.headerTone) ?? shorthand;
let activityTone = region(it.activityTone) ?? shorthand;
// An entry that names no region and carries no shorthand still means "tint this
// day" — the dayId is the payload, the colour is decoration. Give it the default
// tone everywhere rather than returning a contribution that paints nothing.
if (!badgeTone && !headerTone && !activityTone) badgeTone = headerTone = activityTone = 'default';
out.push({
pluginId,
dayId,
...(badgeTone ? { badgeTone } : {}),
...(headerTone ? { headerTone } : {}),
...(activityTone ? { activityTone } : {}),
...(label ? { label } : {}),
});
}
return out;
}
@Controller('api/day-tints')
@UseGuards(JwtAuthGuard)
export class DayTintsController {
constructor(
private readonly runtime: PluginRuntimeService,
private readonly dbs: DatabaseService,
) {}
@Get(':tripId')
async get(
@Param('tripId') tripIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ tints: DayTint[] }> {
if (!pluginsEnabled()) return { tints: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { tints: [] };
const ids = this.runtime.providersOf('dayTintProvider');
if (ids.length === 0) return { tints: [] };
const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>;
const tripDayIds: ReadonlySet<number> = new Set(dayRows.map((d) => d.id));
const perProvider = await Promise.all(
ids.map(async (id): Promise<DayTint[]> => {
try {
const raw = await this.runtime.invokeHook(id, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000);
return normalize(id, tripDayIds, raw);
} catch {
return []; // a slow / failing provider contributes nothing
}
}),
);
// Precedence across plugins: the first GRANTED provider wins a contested day.
// Promise.all preserves input order, so the flattened array is already in
// providersOf() order — keeping the first occurrence is the whole rule. This is
// deterministic, so a day can never flicker between two plugins' colours.
const claimed = new Set<number>();
const tints: DayTint[] = [];
for (const t of perProvider.flat()) {
if (claimed.has(t.dayId)) continue;
claimed.add(t.dayId);
tints.push(t);
}
return { tints };
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { MapMarkersController } from './map-markers.controller';
import { MapLayersController } from './map-layers.controller';
import { PluginRoutesController } from './plugin-routes.controller';
import { DayScheduleController } from './day-schedule.controller';
import { DayTintsController } from './day-tints.controller';
import { PluginActivityController } from './plugin-activity.controller';
import { PdfSectionsController } from './pdf-sections.controller';
import { AtlasLayersController } from './atlas-layers.controller';
@@ -56,7 +57,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, DayTintsController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
// required addon was just turned off (#plugins dependencies).
@@ -327,6 +327,7 @@ export const KNOWN_PERMISSIONS = [
'hook:map-layer-provider',
'hook:route-provider',
'hook:day-schedule-provider',
'hook:day-tint-provider',
'hook:pdf-section-provider',
'hook:atlas-layer-provider',
'hook:journal-entry-provider',
@@ -549,6 +549,27 @@ export interface DayScheduleProvider {
getSchedule(tripId: number, ctx: PluginContext): Promise<DayScheduleContribution[]>;
}
/** A colour the host paints into one day card in the Plan sidebar (and into that day's
* mobile chip) — so a trip split into legs shows its leg membership while you scroll
* the itinerary. Tone only, never a hex: the host picks the alpha per theme and per
* region. The card has three separately tintable regions; `tone` colours all of them. */
export interface DayTintContribution {
dayId: number; // must be a day of the requested trip
tone?: ContributionTone; // shorthand for every region not named below
badgeTone?: ContributionTone; // the day-number badge (and the mobile day chip)
headerTone?: ContributionTone; // the day header row
activityTone?: ContributionTone; // the expanded activity list
label?: string; // optional tooltip on the day (≤60 chars)
}
export interface DayTintProvider {
/** Return one entry per day you want coloured. Runs with the current user bound, on
* a short timeout; a failing call is skipped. A day takes at most one contribution,
* resolved whole: within your own list the first entry for a day wins, and across
* plugins the first granted provider wins, so a day never flickers between two
* colours and two plugins can never each own part of one card. */
getDayTints(tripId: number, ctx: PluginContext): Promise<DayTintContribution[]>;
}
/** A text-only section the host appends to a trip's PDF export. Declarative only —
* plain strings the host lays out and escapes; no markup ever reaches the document. */
export interface PdfSection {
@@ -659,6 +680,7 @@ export interface PluginDefinition {
mapLayerProvider?: MapLayerProvider;
routeProvider?: RouteProvider;
dayScheduleProvider?: DayScheduleProvider;
dayTintProvider?: DayTintProvider;
pdfSectionProvider?: PdfSectionProvider;
atlasLayerProvider?: AtlasLayerProvider;
journalEntryProvider?: JournalEntryProvider;
@@ -107,6 +107,7 @@ const HOOK_PERMISSION: Readonly<Record<string, string>> = {
mapLayerProvider: 'hook:map-layer-provider',
routeProvider: 'hook:route-provider',
dayScheduleProvider: 'hook:day-schedule-provider',
dayTintProvider: 'hook:day-tint-provider',
pdfSectionProvider: 'hook:pdf-section-provider',
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',
@@ -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<string, unknown> = {}) => ({ 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();
});
});
@@ -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']);
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+2
View File
@@ -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}が必要',
+2
View File
@@ -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} 필요',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+2
View File
@@ -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}',
+2
View File
@@ -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}',
+59
View File
@@ -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)
+2
View File
@@ -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 |
+1
View File
@@ -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`. |