Apuntes DAM
Volver al inicio

Un mashup en PHP: la agenda de actividades con la previsión del tiempo

Ejercicio de PHPMedioUnos 60 minutos

Combina dos servicios web en una aplicación híbrida: la agenda de actividades de un ayuntamiento y la previsión de Open-Meteo, en JSON. Valida las dos respuestas, los cruza por fecha y avisa de las actividades al aire libre con riesgo de lluvia, sin romperse si un servicio falla.

  • Aplicaciones híbridas (mashups)
  • Consumir APIs JSON con json_decode
  • Cruzar datos de dos servicios
  • DateTime::createFromFormat
  • usort con el operador nave espacial
  • Degradación elegante ante errores

Enunciado

Una aplicación híbrida (mashup) junta datos de varios servicios web para dar algo que ninguno da por separado. Aquí, la agenda de actividades del ayuntamiento (con el formato de los portales de datos abiertos: una lista @graph con title, dtstart, event-location, free y price, más un campo aire-libre) y la previsión diaria de Open-Meteo (listas paralelas en daily: time, weathercode, temperaturas y probabilidad de lluvia).

En una aplicación real, las dos respuestas llegarían con file_get_contents o cURL; aquí llegan por la entrada, separadas por una línea ---, para poder probar también qué pasa cuando un servicio falla. Un mashup bien hecho no se cae si una de sus fuentes no responde: sigue con lo que tiene y avisa.

Qué tiene que hacer el programa

  1. Si la agenda no es JSON o no tiene la lista @graph: Error: la agenda no es válida y nada más. Si la respuesta del tiempo no es JSON: Aviso: el servicio del tiempo ha devuelto una respuesta que no es JSON; la agenda va sin previsión; si trae "error": true: Aviso: el servicio del tiempo no responde (motivo); la agenda va sin previsión (el motivo está en reason). En los dos casos se sigue sin tiempo.
  2. Un evento sin dtstart válido (formato AAAA-MM-DD HH:MM:SS y fecha que existe) se salta con Aviso: el evento ID no tiene una fecha válida; sin título (o solo espacios), con Aviso: el evento ID no tiene título. Los avisos salen en el orden de la agenda, antes que los días.
  3. Los eventos se ordenan por fecha y hora (y por título si coinciden) y se agrupan por día. Cada día: Día D de mes: (el día de la semana con mayúscula inicial) y la previsión Descripción, MÍN–MÁX °C, lluvia P % (temperaturas redondeadas; descripción de la tabla o código N si no está) o sin previsión del tiempo si no hay datos de esa fecha.
  4. Cada evento: dos espacios, HH:MM título, (lugar) si lo tiene, · y el precio (gratis si free es 1; si no, price con dos decimales, coma y €, o precio sin confirmar si no hay o no es un número), y si es al aire libre · al aire libre y, si la probabilidad de lluvia del día es del 50 % o más, · ¡Ojo! P % de probabilidad de lluvia.
  5. Al final: N actividades en D días; R al aire libre con riesgo de lluvia (1 actividad, 1 día).

Entrada

La respuesta JSON de la agenda (puede ocupar varias líneas).

Una línea ---.

La respuesta JSON del servicio del tiempo.

Datos de referencia

Códigos del tiempo (OMM)
CódigoDescripción
0Despejado
1, 2Poco nuboso
3Nublado
45, 48Niebla
51, 53, 55Llovizna
61Lluvia ligera
63Lluvia
65Lluvia fuerte
80, 81Chubascos
82Chubascos fuertes
95Tormenta

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Tres días de actividades

Entrada

{
  "@context": "https://www.example.org/agenda",
  "@graph": [
    {
      "id": "e1",
      "title": "Teatro: La vida es sueño",
      "dtstart": "2026-10-09 20:00:00",
      "event-location": "Teatro del Barrio",
      "free": 0,
      "price": "12"
    },
    {
      "id": "e2",
      "title": "Concierto de jazz en el parque",
      "dtstart": "2026-10-08 19:30:00",
      "event-location": "Parque del Oeste",
      "free": 1,
      "aire-libre": 1
    },
    {
      "id": "e3",
      "title": "Ruta en bici junto al río",
      "dtstart": "2026-10-09 18:00:00",
      "event-location": "Madrid Río",
      "free": 0,
      "price": "5",
      "aire-libre": 1
    },
    {
      "id": "e4",
      "title": "Taller de robótica para familias",
      "dtstart": "2026-10-10 11:00:00",
      "event-location": "Biblioteca municipal",
      "free": 1
    },
    {
      "id": "e5",
      "title": "Mercadillo de segunda mano",
      "dtstart": "2026-10-10 10:00:00",
      "event-location": "Plaza Mayor",
      "free": 1,
      "aire-libre": 1
    }
  ]
}
---
{"latitude": 40.42, "longitude": -3.7, "timezone": "Europe/Madrid", "daily_units": {"temperature_2m_max": "°C", "precipitation_probability_max": "%"}, "daily": {"time": ["2026-10-08", "2026-10-09", "2026-10-10"], "weathercode": [0, 61, 3], "temperature_2m_max": [24.1, 17.4, 19.6], "temperature_2m_min": [12.2, 10.5, 11.0], "precipitation_probability_max": [0, 80, 35]}}

Salida por consola

Jueves 8 de octubre: Despejado, 12–24 °C, lluvia 0 %
  19:30 Concierto de jazz en el parque (Parque del Oeste) · gratis · al aire libre
Viernes 9 de octubre: Lluvia ligera, 11–17 °C, lluvia 80 %
  18:00 Ruta en bici junto al río (Madrid Río) · 5,00 € · al aire libre · ¡Ojo! 80 % de probabilidad de lluvia
  20:00 Teatro: La vida es sueño (Teatro del Barrio) · 12,00 €
Sábado 10 de octubre: Nublado, 11–20 °C, lluvia 35 %
  10:00 Mercadillo de segunda mano (Plaza Mayor) · gratis · al aire libre
  11:00 Taller de robótica para familias (Biblioteca municipal) · gratis
5 actividades en 3 días; 1 al aire libre con riesgo de lluvia

Datos incompletos y el servicio del tiempo caído

Entrada

{
  "@context": "https://www.example.org/agenda",
  "@graph": [
    {
      "id": "e1",
      "title": "Cine de verano",
      "dtstart": "2026-10-11 21:30:00",
      "event-location": "Terraza del centro cultural",
      "free": 0,
      "price": "3,50",
      "aire-libre": 1
    },
    {
      "id": "e2",
      "title": "Exposición de fotografía",
      "dtstart": "2026-10-12 10:00:00",
      "free": 1
    },
    {
      "id": "e3",
      "title": "Carrera solidaria",
      "dtstart": "2026-10-11 09:00:00",
      "event-location": "Casa de Campo",
      "free": 0,
      "price": "15.5",
      "aire-libre": 1
    },
    {
      "id": "e4",
      "title": "",
      "dtstart": "2026-10-11 12:00:00",
      "free": 0
    },
    {
      "id": "e5",
      "title": "Fiesta sin fecha",
      "dtstart": "2026-13-01 12:00:00",
      "free": 0
    },
    {
      "id": "e6",
      "title": "Recital de poesía",
      "dtstart": "2026-10-11 19:00:00",
      "free": 0
    }
  ]
}
---
{"error": true, "reason": "Too many requests"}

Salida por consola

Aviso: el servicio del tiempo no responde (Too many requests); la agenda va sin previsión
Aviso: el evento e4 no tiene título
Aviso: el evento e5 no tiene una fecha válida
Domingo 11 de octubre: sin previsión del tiempo
  09:00 Carrera solidaria (Casa de Campo) · 15,50 € · al aire libre
  19:00 Recital de poesía · precio sin confirmar
  21:30 Cine de verano (Terraza del centro cultural) · precio sin confirmar · al aire libre
Lunes 12 de octubre: sin previsión del tiempo
  10:00 Exposición de fotografía · gratis
4 actividades en 2 días; 0 al aire libre con riesgo de lluvia

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Desconfía de cada respuesta

json_decode($texto, true) devuelve null si el texto no es JSON. Comprueba también que está lo que necesitas (@graph, daily) antes de usarlo: un servicio puede cambiar o fallar.

php
$datos = json_decode($texto, true);
if (!is_array($datos)) return "... no es JSON";
if (!empty($datos["error"])) return "... no responde (" . $datos["reason"] . ")";
2. De listas paralelas a un mapa

Open-Meteo devuelve una lista de fechas y, en la misma posición de otras listas, sus datos. Conviértelo en un array indexado por fecha y cruzar con la agenda será un simple $tiempo[$fecha] ?? null.

3. Fechas de verdad

DateTime::createFromFormat("Y-m-d H:i:s", $texto) lee la fecha; para descartar fechas imposibles (mes 13), compara el resultado formateado con el texto original. format("N") da el día de la semana (1 = lunes).

4. Ordenar por varios criterios

usort con [$a["dtstart"], $a["title"]] <=> [$b["dtstart"], $b["title"]] compara primero la fecha y, si coincide, el título.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.

🐘PHPUn mashup en PHP: la agenda de actividades con la previsión del tiempoMedio

Ejemplo

Entrada (lo que se escribe por teclado)
{
  "@context": "https://www.example.org/agenda",
  "@graph": [
    {
      "id": "e1",
      "title": "Teatro: La vida es sueño",
      "dtstart": "2026-10-09 20:00:00",
      "event-location": "Teatro del Barrio",
      "free": 0,
      "price": "12"
    },
    {
      "id": "e2",
      "title": "Concierto de jazz en el parque",
      "dtstart": "2026-10-08 19:30:00",
      "event-location": "Parque del Oeste",
      "free": 1,
      "aire-libre": 1
    },
    {
      "id": "e3",
      "title": "Ruta en bici junto al río",
      "dtstart": "2026-10-09 18:00:00",
      "event-location": "Madrid Río",
      "free": 0,
      "price": "5",
      "aire-libre": 1
    },
    {
      "id": "e4",
      "title": "Taller de robótica para familias",
      "dtstart": "2026-10-10 11:00:00",
      "event-location": "Biblioteca municipal",
      "free": 1
    },
    {
      "id": "e5",
      "title": "Mercadillo de segunda mano",
      "dtstart": "2026-10-10 10:00:00",
      "event-location": "Plaza Mayor",
      "free": 1,
      "aire-libre": 1
    }
  ]
}
---
{"latitude": 40.42, "longitude": -3.7, "timezone": "Europe/Madrid", "daily_units": {"temperature_2m_max": "°C", "precipitation_probability_max": "%"}, "daily": {"time": ["2026-10-08", "2026-10-09", "2026-10-10"], "weathercode": [0, 61, 3], "temperature_2m_max": [24.1, 17.4, 19.6], "temperature_2m_min": [12.2, 10.5, 11.0], "precipitation_probability_max": [0, 80, 35]}}
Salida esperada
Jueves 8 de octubre: Despejado, 12–24 °C, lluvia 0 %
  19:30 Concierto de jazz en el parque (Parque del Oeste) · gratis · al aire libre
Viernes 9 de octubre: Lluvia ligera, 11–17 °C, lluvia 80 %
  18:00 Ruta en bici junto al río (Madrid Río) · 5,00 € · al aire libre · ¡Ojo! 80 % de probabilidad de lluvia
  20:00 Teatro: La vida es sueño (Teatro del Barrio) · 12,00 €
Sábado 10 de octubre: Nublado, 11–20 °C, lluvia 35 %
  10:00 Mercadillo de segunda mano (Plaza Mayor) · gratis · al aire libre
  11:00 Taller de robótica para familias (Biblioteca municipal) · gratis
5 actividades en 3 días; 1 al aire libre con riesgo de lluvia
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
1<?php
2const DIAS = [1 => "lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"];
3const MESES = [1 => "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"];
4// Códigos del tiempo de la OMM (los que usa Open-Meteo)
5const TIEMPO = [
6    0 => "Despejado", 1 => "Poco nuboso", 2 => "Poco nuboso", 3 => "Nublado", 45 => "Niebla", 48 => "Niebla",
7    51 => "Llovizna", 53 => "Llovizna", 55 => "Llovizna", 61 => "Lluvia ligera", 63 => "Lluvia", 65 => "Lluvia fuerte",
8    80 => "Chubascos", 81 => "Chubascos", 82 => "Chubascos fuertes", 95 => "Tormenta",
9];
10const RIESGO_LLUVIA = 50;      // % a partir del cual se avisa en las actividades al aire libre
11
12[$textoAgenda, $textoTiempo] = array_pad(explode("\n---\n", str_replace("\r", "", stream_get_contents(STDIN)), 2), 2, "");
13
14/** El tiempo por fecha: ["2026-10-08" => ["codigo" => 0, "max" => 24.1, ...]], o un aviso si el servicio falla. */
15function leerTiempo(string $texto): array|string
16{
17    $datos = json_decode($texto, true);
18    if (!is_array($datos)) return "el servicio del tiempo ha devuelto una respuesta que no es JSON";
19    if (!empty($datos["error"])) return "el servicio del tiempo no responde (" . ($datos["reason"] ?? "sin motivo") . ")";
20    $d = $datos["daily"] ?? [];
21    $porFecha = [];
22    foreach ($d["time"] ?? [] as $k => $fecha) {
23        $porFecha[$fecha] = [
24            "codigo" => $d["weathercode"][$k] ?? null,
25            "max" => $d["temperature_2m_max"][$k] ?? null,
26            "min" => $d["temperature_2m_min"][$k] ?? null,
27            "lluvia" => $d["precipitation_probability_max"][$k] ?? null,
28        ];
29    }
30    return $porFecha;
31}
32
33function precio(array $e): string
34{
35    if (!empty($e["free"])) return "gratis";
36    if (!isset($e["price"]) || !is_numeric($e["price"])) return "precio sin confirmar";
37    return number_format((float) $e["price"], 2, ",", ".") . " €";
38}
39
40$agenda = json_decode($textoAgenda, true);
41if (!is_array($agenda) || !isset($agenda["@graph"]) || !is_array($agenda["@graph"])) {
42    echo "Error: la agenda no es válida\n";
43    exit;
44}
45$tiempo = leerTiempo($textoTiempo);
46if (is_string($tiempo)) {
47    echo "Aviso: $tiempo; la agenda va sin previsión\n";
48    $tiempo = [];
49}
50
51$eventos = [];
52foreach ($agenda["@graph"] as $e) {
53    $id = $e["id"] ?? "?";
54    $cuando = DateTime::createFromFormat("Y-m-d H:i:s", $e["dtstart"] ?? "");
55    if ($cuando === false || $cuando->format("Y-m-d H:i:s") !== $e["dtstart"]) {
56        echo "Aviso: el evento $id no tiene una fecha válida\n";
57        continue;
58    }
59    if (trim($e["title"] ?? "") === "") {
60        echo "Aviso: el evento $id no tiene título\n";
61        continue;
62    }
63    $eventos[] = $e + ["cuando" => $cuando];
64}
65usort($eventos, fn($a, $b) => [$a["dtstart"], $a["title"]] <=> [$b["dtstart"], $b["title"]]);
66
67$porDia = [];
68foreach ($eventos as $e) $porDia[$e["cuando"]->format("Y-m-d")][] = $e;
69$riesgo = 0;
70foreach ($porDia as $fecha => $lista) {
71    $f = $lista[0]["cuando"];
72    $cabecera = ucfirst(DIAS[(int) $f->format("N")]) . " " . $f->format("j") . " de " . MESES[(int) $f->format("n")] . ": ";
73    $t = $tiempo[$fecha] ?? null;
74    if ($t === null || $t["codigo"] === null) {
75        $cabecera .= "sin previsión del tiempo";
76    } else {
77        $cabecera .= (TIEMPO[$t["codigo"]] ?? "código " . $t["codigo"]) . ", " . round($t["min"]) . "–" . round($t["max"]) . " °C, lluvia " . $t["lluvia"] . " %";
78    }
79    echo "$cabecera\n";
80    foreach ($lista as $e) {
81        $linea = "  " . $e["cuando"]->format("H:i") . " " . trim($e["title"]);
82        if (!empty($e["event-location"])) $linea .= " (" . $e["event-location"] . ")";
83        $linea .= " · " . precio($e);
84        if (!empty($e["aire-libre"])) {
85            $linea .= " · al aire libre";
86            if ($t !== null && $t["lluvia"] !== null && $t["lluvia"] >= RIESGO_LLUVIA) {
87                $linea .= " · ¡Ojo! " . $t["lluvia"] . " % de probabilidad de lluvia";
88                $riesgo++;
89            }
90        }
91        echo "$linea\n";
92    }
93}
94$n = count($eventos);
95$d = count($porDia);
96echo "$n " . ($n === 1 ? "actividad" : "actividades") . " en $d " . ($d === 1 ? "día" : "días") . "; $riesgo al aire libre con riesgo de lluvia\n";

El valor de un mashup está en el cruce: la agenda sola no sabe que va a llover, y la previsión sola no sabe qué hay programado. La clave para unirlos es un campo común, aquí la fecha.

La degradación elegante es la regla de oro con servicios de terceros: si el del tiempo falla (cuota agotada, caída, respuesta rara), la agenda se muestra igual con un aviso. Una aplicación que muestra un error en blanco porque falló un servicio secundario es un mal mashup.

En producción se añadirían una caché (la previsión no cambia cada segundo y las APIs limitan el número de peticiones), tiempos de espera cortos con cURL y, a veces, peticiones en paralelo.

Los portales de datos abiertos de muchos ayuntamientos publican sus agendas en JSON con formatos parecidos a este: son una buena fuente para proyectos de clase.

Para ir más allá

  • Haz las peticiones de verdad con cURL a Open-Meteo, con un tiempo de espera de 3 segundos.
  • Guarda la respuesta del tiempo en una caché en fichero durante una hora.
  • Sugiere, para cada actividad al aire libre con riesgo de lluvia, otra cubierta del mismo día.

Dónde se explica