DayPlan

←→

Départ, arrivée et lumière du jour — bouton flottant de l’heure d’arrivée et son panneau, où un ruban montre la journée et la sortie posée dessus, à l’heure du pays de la trace.

stableÀ valider
// Titre : Bouton de l’heure d’arrivée, sortie de 4 h 30
import DayPlan from '@pathou/components/DayPlan';
import { base } from '../_fixtures/base';

export default function Exemple() {
  return (
    <div style={{ position: 'relative', width: 380, minHeight: 80 }}>
      <DayPlan base={base} hours={4.5} />
    </div>
  );
}

Installation

Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.

src/components/DayPlan.tsx
import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDismiss } from '../hooks/useDismiss';
import { usePresence } from '../hooks/usePresence';
import type { BaseAnalysis } from '../lib/analyze';
import { regionAt } from '../lib/sources/regions';
import { load, save } from '../lib/storage';
import { clock, parseClock, sunTimes, timeZoneOf, wallClock, zonedMs } from '../lib/sun';
import { formatHours } from '../lib/time';

const DEPART_KEY = 'trace3d.depart';
const DEFAULT_DEPART = '09:00';
/** heures valides en mémoire (HH:MM) */
const MINUTES = Array.from({ length: 24 * 60 }, (_, k) => `${String(Math.floor(k / 60)).padStart(2, '0')}:${String(k % 60).padStart(2, '0')}`);
const H = 3600000;
const MIN = 60000;
const dayFmt = new Intl.DateTimeFormat('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' });
const dayOf = (iso: string) => new Date(`${iso}T12:00:00Z`);
const addDays = (iso: string, n: number) => new Date(dayOf(iso).getTime() + n * 86400000).toISOString().slice(0, 10);
const toMinute = (ms: number) => Math.round(ms / MIN) * MIN;
/** « 45 min », « 3 h 32 » */
const duration = (ms: number) => {
  const m = Math.round(Math.abs(ms) / MIN);
  return m < 60 ? `${m} min` : formatHours(m / 60).replace(/ /g, ' ');
};
const at = (ms: number, tz?: string) => clock(wallClock(ms, tz)).replace(/ /g, ' ');

type State = 'ok' | 'tight' | 'late';

/**
 * Départ, arrivée et lumière du jour : bouton flottant de la carte (l'heure d'arrivée) et son panneau, où un ruban
 * montre la journée (nuit, jour du lever au coucher) et la sortie posée dessus, du départ à l'arrivée en roulant sans pause.
 * Heures du pays de la trace ; le départ (heure) est mémorisé, le jour repart d'aujourd'hui.
 */
export default function DayPlan({ base, hours }: { base: BaseAnalysis; hours: number }) {
  const first = base.samples[0];
  const end = base.samples[base.samples.length - 1];
  const [tzStart, tzEnd] = useMemo(
    () => [timeZoneOf(regionAt(first.lat, first.lon), first.lon), timeZoneOf(regionAt(end.lat, end.lon), end.lon)],
    [first, end],
  );
  const today = useMemo(() => wallClock(Date.now(), tzStart).date, [tzStart]);
  const [time, setTime] = useState(() => load(DEPART_KEY, MINUTES, DEFAULT_DEPART));
  const [date, setDate] = useState(today);
  const [open, setOpen] = useState(false);
  const root = useRef<HTMLDivElement>(null);
  useDismiss(open, root, () => setOpen(false));
  const panel = usePresence(open, 150);
  const menu = useRef<HTMLDivElement>(null);
  // Sous son bouton, comme « Contenu de la carte ». Sur ordinateur, décalé vers la gauche s'il passerait sous le
  // panneau du verdict (à droite de la carte) ; sur mobile, il prend toute la largeur (styles.css).
  const [shift, setShift] = useState(0);
  useLayoutEffect(() => {
    if (!panel.mounted) return;
    const place = () => {
      const el = menu.current;
      const wrap = root.current?.closest('.map-wrap');
      if (!el || !wrap || !root.current || !window.matchMedia('(min-width: 901px)').matches) return setShift(0);
      const btn = root.current.getBoundingClientRect();
      const area = wrap.getBoundingClientRect();
      const side = parseFloat(getComputedStyle(wrap).getPropertyValue('--side-inset')) || 0;
      const over = btn.left + el.offsetWidth - (area.right - side - 10);
      setShift(over > 0 ? -Math.min(over, btn.left - area.left - 10) : 0);
    };
    place();
    // la carte change de taille (fenêtre, panneau du verdict, rotation du téléphone) : le panneau se recale
    const wrap = root.current?.closest('.map-wrap');
    const ro = new ResizeObserver(place);
    if (wrap) ro.observe(wrap);
    return () => ro.disconnect();
  }, [panel.mounted]);

  const depart = zonedMs(date, time, tzStart);
  // à la minute, comme le temps estimé du profil : 9 h 00 + 4 h 02 = 13 h 02
  const arrive = depart + Math.round(hours * 60) * MIN;
  const arriveDay = wallClock(arrive, tzEnd).date;
  const sunStart = sunTimes(zonedMs(date, '12:00', tzStart), first.lat, first.lon);
  const sunEnd = sunTimes(zonedMs(arriveDay, '12:00', tzEnd), end.lat, end.lon);
  const sunset = sunEnd && toMinute(sunEnd.set);
  const margin = sunset === null ? null : sunset - arrive;
  const state: State = margin === null || margin >= H ? 'ok' : margin >= 0 ? 'tight' : 'late';
  const nextDay = arriveDay !== date;
  const dayLabel = date === today ? 'aujourd’hui' : date === addDays(today, 1) ? 'demain' : dayFmt.format(dayOf(date));

  const ride = Math.round(hours * 60) * MIN;
  const sunsetAt = sunset === null ? '' : at(sunset, tzEnd);
  /** dernier départ (heure murale du départ) qui laisse `keep` de jour à l'arrivée */
  const latest = (keep: number) => at(sunset! - ride - keep, tzStart);
  // trop longue pour la journée : même au lever du soleil, on finirait de nuit
  const tooLong = sunset !== null && sunStart !== null && sunset - ride < toMinute(sunStart.rise);
  const summary =
    margin === null
      ? 'Le soleil ne se couche pas ou ne se lève pas ce jour-là à cette latitude.'
      : margin >= H
        ? `Il te reste ${duration(margin)} de jour pour tes pauses\u00a0: le soleil se couche à ${sunsetAt}.`
        : margin >= 0
          ? `Il ne te reste que ${duration(margin)} de jour pour tes pauses\u00a0: le soleil se couche à ${sunsetAt}. Pars avant ${latest(H)} pour en garder une heure.`
          : tooLong
            ? `Tu arriverais de nuit, même en partant au lever du soleil (${at(toMinute(sunStart!.rise), tzStart)})\u00a0: prévois un éclairage ou coupe la sortie en deux.`
            : `Tu arriverais ${duration(margin)} après le coucher du soleil (${sunsetAt}). Pour arriver de jour, même sans pause, pars avant ${latest(0)}.`;
  const stateText = state === 'late' ? ', après le coucher du soleil' : state === 'tight' ? ', moins d’une heure avant le coucher du soleil' : '';

  return (
    <div ref={root} className="map-content day-plan">
      <button
        className={`map-fab day-fab day-${state}${open ? ' open' : ''}`}
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-haspopup="dialog"
        aria-controls="day-plan-menu"
        // nom lu = texte visible (« Arrivée 13 h 02 », « Arrivée » masqué sur mobile), puis l'état de la marge
        aria-label={`Arrivée ${at(arrive, tzEnd)}${stateText}`}
        title="Choisir l’heure de départ et voir la marge avant la nuit"
      >
        <SunIcon />
        <span className="map-fab-label">Arrivée</span>
        <span className="day-fab-time">{at(arrive, tzEnd)}</span>
        {state !== 'ok' && <span className="day-fab-dot" aria-hidden="true" />}
      </button>

      {panel.mounted && (
        <div
          ref={menu}
          id="day-plan-menu"
          className={`map-menu day-menu${panel.closing ? ' closing' : ''}`}
          style={shift ? { left: shift } : undefined}
          inert={panel.closing}
          role="dialog"
          aria-label="Heure de départ et d’arrivée"
        >
          <div className="map-menu-head">
            <h2 className={`day-title day-${state}`}>
              Arrivée vers {at(arrive, tzEnd)}
              {nextDay && <small> le {dayFmt.format(dayOf(arriveDay))}</small>}
            </h2>
            <button className="map-menu-close" onClick={() => setOpen(false)} aria-label="Fermer">
              <svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
                <path d="M5 5l10 10M15 5L5 15" />
              </svg>
            </button>
          </div>

          <div className="day-fields">
            <DepartInput
              value={time}
              onChange={(t) => {
                setTime(t);
                save(DEPART_KEY, t);
              }}
            />
            <label>
              <span>Jour du départ</span>
              <input type="date" value={date} min={today} onChange={(e) => e.target.value && setDate(e.target.value)} aria-describedby="day-plan-day" />
              <small id="day-plan-day">{dayLabel}</small>
            </label>
          </div>

          <DayRibbon depart={depart} arrive={arrive} days={[sunStart, ...(nextDay ? [sunEnd] : [])]} state={state} tzStart={tzStart} tzEnd={tzEnd} />

          <div className="day-text">
            <p className={`day-summary day-${state}`}>
              {/* même pastille que le bouton : orange sous une heure de marge, rouge après le coucher */}
              {state !== 'ok' && <span className="day-summary-dot" aria-hidden="true" />}
              {summary}
            </p>
            <p className="day-note">
              Calculé sur {formatHours(ride / H)} en roulant, sans pause, d’après ton niveau et ta vitesse.{' '}
              {tzEnd ? 'Heures locales.' : 'Heures de ton appareil\u00a0: le fuseau horaire de l’arrivée n’est pas connu.'}
            </p>
          </div>
        </div>
      )}
    </div>
  );
}

const shown = (t: string) => clock({ h: Number(t.slice(0, 2)), m: Number(t.slice(3)) });

/**
 * Heure de départ saisie au clavier, comme la vitesse du profil : écrite librement (« 9h30 », « 930 »…), retenue
 * à Entrée ou en quittant le champ ; une saisie illisible est signalée, puis abandonnée en quittant le champ.
 */
function DepartInput({ value, onChange }: { value: string; onChange: (t: string) => void }) {
  const [draft, setDraft] = useState<string | null>(null);
  const hint = useId();
  const parsed = draft === null ? value : parseClock(draft);
  const invalid = draft !== null && parsed === null;
  const commit = () => {
    if (parsed && parsed !== value) onChange(parsed);
    setDraft(null);
  };
  return (
    <label>
      <span>Heure de départ</span>
      <input
        className={`day-time${invalid ? ' invalid' : ''}`}
        value={draft ?? shown(value)}
        inputMode="decimal"
        autoComplete="off"
        spellCheck={false}
        aria-invalid={invalid}
        aria-describedby={hint}
        onFocus={(e) => {
          setDraft(shown(value));
          e.target.select();
        }}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') e.currentTarget.blur();
          if (e.key === 'Escape') {
            // Échap annule la saisie sans fermer le panneau
            e.stopPropagation();
            setDraft(null);
            e.currentTarget.blur();
          }
        }}
        onBlur={commit}
      />
      {/* ligne d'aide toujours là (comme « aujourd'hui » sous le jour) : l'erreur la remplace sans décaler le panneau */}
      <small id={hint} className={invalid ? 'day-error' : undefined}>
        {invalid ? (
          <>
            Heure non reconnue<span className="sr-only">, écris par ex. 9 h 30</span>
          </>
        ) : (
          'ex. 9 h 30'
        )}
      </small>
    </label>
  );
}

function SunIcon() {
  return (
    <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M7 17a5 5 0 0 1 10 0" />
      <path d="M3 17h18M12 7V5M5.6 10.6 4.2 9.2M18.4 10.6l1.4-1.4M8 21h8" />
    </svg>
  );
}

/**
 * La journée à l'échelle : nuit en fond, jour du lever au coucher, la sortie posée dessus. La part de la sortie
 * après le coucher passe en rouge ; l'écart entre l'arrivée et le coucher, c'est la marge pour les pauses.
 */
function DayRibbon({
  depart,
  arrive,
  days,
  state,
  tzStart,
  tzEnd,
}: {
  depart: number;
  arrive: number;
  days: ({ rise: number; set: number } | null)[];
  state: State;
  tzStart?: string;
  tzEnd?: string;
}) {
  const suns = days.filter((d): d is { rise: number; set: number } => d !== null).map((d) => ({ rise: toMinute(d.rise), set: toMinute(d.set) }));
  // de l'heure pleine avant le lever (ou le départ) à l'heure pleine après le coucher (ou l'arrivée)
  const lo = Math.floor((Math.min(depart, ...suns.map((s) => s.rise)) - 30 * MIN) / H) * H;
  const hi = Math.ceil((Math.max(arrive, ...suns.map((s) => s.set)) + 30 * MIN) / H) * H;
  const x = (ms: number) => `${(((ms - lo) / (hi - lo)) * 100).toFixed(2)}%`;
  const w = (a: number, b: number) => `${(((b - a) / (hi - lo)) * 100).toFixed(2)}%`;
  const lastSet = suns.length ? suns[suns.length - 1].set : null;
  const dark = lastSet !== null && arrive > lastSet ? Math.max(depart, lastSet) : null;
  const first = suns[0];

  return (
    <div className="day-ribbon" role="img" aria-label={`Journée : sortie de ${at(depart, tzStart)} à ${at(arrive, tzEnd)}${first ? `, soleil levé à ${at(first.rise, tzStart)} et couché à ${at(lastSet!, tzEnd)}` : ''}.`}>
      <div className="day-track">
        {suns.map((s) => (
          <span key={s.rise} className="day-light" style={{ left: x(s.rise), width: w(s.rise, s.set) }} />
        ))}
        <span className="day-ride" style={{ left: x(depart), width: w(depart, dark ?? arrive) }} />
        {dark !== null && <span className="day-ride day-ride-dark" style={{ left: x(dark), width: w(dark, arrive) }} />}
        {state !== 'late' && lastSet !== null && arrive < lastSet && <span className={`day-gap day-${state}`} style={{ left: x(arrive), width: w(arrive, lastSet) }} />}
      </div>
      <div className="day-scale" aria-hidden="true">
        {first && (
          <span className="day-tick" style={{ left: x(first.rise) }}>
            Lever {at(first.rise, tzStart)}
          </span>
        )}
        {lastSet !== null && (
          <span className="day-tick day-tick-end" style={{ left: x(lastSet) }}>
            Coucher {at(lastSet, tzEnd)}
          </span>
        )}
      </div>
    </div>
  );
}

Tokens : --bg --panel --text --muted --line --accent --chip --shadow --engage (non déterminé) --exigeant --ease-out

Utilisation

import DayPlan from './components/DayPlan';

<DayPlan base={base} hours={rider.hours} />

Exemples

Panneau ouvert

// Titre : Panneau ouvert
import DayPlan from '@pathou/components/DayPlan';
import Clicked from '../../src/preview/Clicked';
import { base } from '../_fixtures/base';

// <Clicked> ouvre le panneau au chargement de l’aperçu ; dans Pathou, c’est le clic sur le bouton
export default function Exemple() {
  return (
    <div style={{ position: 'relative', width: 420, minHeight: 420 }}>
      <Clicked selector=".day-fab">
        <DayPlan base={base} hours={4.5} />
      </Clicked>
    </div>
  );
}

Anatomie

#ÉlémentRôle
1BoutonL’heure d’arrivée estimée, et une pastille d’état.
2ChampsHeure et jour de départ ; l’heure est mémorisée, le jour repart d’aujourd’hui.
3RubanLa journée (nuit, jour du lever au coucher) et la sortie posée dessus.

États

stateRapport entre la sortie et la lumière du jour, qui colore le bouton et le ruban.

Guidelines

  • MUST
    Calculer les heures dans le fuseau du pays de la trace, pas celui du navigateur.
  • SHOULD
    Dire que la durée est en roulant, sans pause.

Clavier

ToucheEffet
EntréeValide l’heure saisie.
ÉchapAnnule la saisie.

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
hoursnumberrequis