const EURJPY = 182.85;
function mapsLink(q) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`;
}
function jpyToEur(jpy) {
return jpy / EURJPY;
}
function eurToJpy(eur) {
return eur * EURJPY;
}
function formatJpy(n) {
return "¥" + Number(n || 0).toLocaleString("it-IT", { maximumFractionDigits: 0 });
}
function formatEur(n) {
return new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(n || 0);
}
// Minimal “calendar” dataset (lorem ipsum placeholders)
const DAYS = [
{
id: "d01",
dateLabel: "14 giugno",
title: "Tokyo · Arrivo",
city: "Tokyo",
base: "Asakusa",
summary: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
movements: [
{ time: "10:30", from: "Narita", to: "Asakusa", mode: "Train", mapsQuery: "Narita Airport to Asakusa" },
{ time: "15:00", from: "Asakusa", to: "Shibuya", mode: "Metro", mapsQuery: "Asakusa to Shibuya" },
],
attractions: [
{ name: "Lorem Shrine", note: "Lorem ipsum dolor sit amet.", mapsQuery: "Meiji Jingu" },
{ name: "Ipsum Crossing", note: "Sed do eiusmod tempor.", mapsQuery: "Shibuya Crossing" },
],
lunch: [
{ name: "Lorem Ramen", mapsQuery: "Ramen Asakusa" },
{ name: "Ipsum Sushi", mapsQuery: "Sushi Asakusa" },
],
dinner: [
{ name: "Dolor Izakaya", mapsQuery: "Izakaya Shinjuku" },
{ name: "Sit Tempura", mapsQuery: "Tempura Shinjuku" },
],
},
{
id: "d02",
dateLabel: "15 giugno",
title: "Tokyo · Tradizione",
city: "Tokyo",
base: "Asakusa",
summary: "Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
movements: [
{ time: "09:00", from: "Asakusa", to: "Senso-ji", mode: "Walk", mapsQuery: "Senso-ji" },
{ time: "12:00", from: "Senso-ji", to: "Skytree", mode: "Walk", mapsQuery: "Tokyo Skytree" },
],
attractions: [
{ name: "Senso-ji", note: "Lorem ipsum dolor sit amet.", mapsQuery: "Senso-ji" },
{ name: "Tokyo Skytree", note: "Consectetur adipiscing elit.", mapsQuery: "Tokyo Skytree" },
],
lunch: [
{ name: "Lorem Bento", mapsQuery: "Tokyo Solamachi food" },
{ name: "Ipsum Udon", mapsQuery: "Udon Asakusa" },
],
dinner: [
{ name: "Dolor Gyoza", mapsQuery: "Gyoza Akihabara" },
{ name: "Sit Soba", mapsQuery: "Soba Asakusa" },
],
},
];
const elCalendar = document.getElementById("calendar");
const elDayView = document.getElementById("dayView");
function renderCalendar(selectedId) {
elCalendar.innerHTML = DAYS.map((d) => {
const active = d.id === selectedId ? " is-active" : "";
return `
`;
}).join("");
elCalendar.querySelectorAll(".cal-day").forEach((b) => {
b.addEventListener("click", () => {
const id = b.getAttribute("data-id");
renderDay(id);
renderCalendar(id);
location.hash = "#giorno";
});
});
}
function renderDay(dayId) {
const day = DAYS.find((d) => d.id === dayId) || DAYS[0];
const movements = day.movements
.map(
(m) => `
${m.time}
${m.from} → ${m.to}
${m.mode} · Lorem ipsum dolor sit amet.
`
)
.join("");
const attractions = day.attractions
.map(
(a) => `
${a.name}
${a.note}
`
)
.join("");
const lunch = day.lunch
.map((p) => `${p.name}`)
.join("");
const dinner = day.dinner
.map((p) => `${p.name}`)
.join("");
elDayView.innerHTML = `
Dove siete
${day.city} · ${day.base}
`;
}
async function apiFetch(path, options) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
// -----------------
// TODO UI
// -----------------
const elTodoList = document.getElementById("todoList");
const elTodoNewText = document.getElementById("todoNewText");
const elTodoAddBtn = document.getElementById("todoAddBtn");
async function renderTodos() {
const todos = await apiFetch("/api/todos");
elTodoList.innerHTML = todos.length
? todos
.map(
(t) => `
`
)
.join("")
: `Nessuna voce. Lorem ipsum.
`;
elTodoList.querySelectorAll("input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", async () => {
const id = cb.getAttribute("data-id");
await apiFetch(`/api/todos/${id}`, { method: "PATCH", body: JSON.stringify({ done: cb.checked }) });
});
});
elTodoList.querySelectorAll(".todo-del").forEach((b) => {
b.addEventListener("click", async () => {
const id = b.getAttribute("data-id");
await apiFetch(`/api/todos/${id}`, { method: "DELETE" });
await renderTodos();
});
});
elTodoList.querySelectorAll(".todo-edit").forEach((b) => {
b.addEventListener("click", async () => {
const id = b.getAttribute("data-id");
const current = b.closest(".todo-item").querySelector(".todo-check span").textContent;
const next = prompt("Modifica testo", current);
if (next === null) return;
await apiFetch(`/api/todos/${id}`, { method: "PATCH", body: JSON.stringify({ text: next }) });
await renderTodos();
});
});
}
elTodoAddBtn.addEventListener("click", async () => {
const text = (elTodoNewText.value || "").trim();
if (!text) return;
await apiFetch("/api/todos", { method: "POST", body: JSON.stringify({ text }) });
elTodoNewText.value = "";
await renderTodos();
});
elTodoNewText.addEventListener("keydown", async (e) => {
if (e.key === "Enter") {
e.preventDefault();
elTodoAddBtn.click();
}
});
// -----------------
// EXPENSES UI
// -----------------
const elCostList = document.getElementById("costList");
const elCostDesc = document.getElementById("costDesc");
const elCostJpy = document.getElementById("costJpy");
const elCostEur = document.getElementById("costEur");
const elAddCostBtn = document.getElementById("addCostBtn");
async function renderExpenses() {
const expenses = await apiFetch("/api/expenses");
elCostList.innerHTML = expenses.length
? expenses
.map(
(c) => `
${c.description}
${formatJpy(c.jpy)} · ${formatEur(c.eur)}
`
)
.join("")
: `Nessuna spesa inserita. Lorem ipsum.
`;
const totalJpy = expenses.reduce((a, c) => a + Number(c.jpy || 0), 0);
const totalEur = expenses.reduce((a, c) => a + Number(c.eur || 0), 0);
document.getElementById("totalJpy").textContent = formatJpy(totalJpy);
document.getElementById("totalEur").textContent = formatEur(totalEur);
elCostList.querySelectorAll(".cost-del").forEach((b) => {
b.addEventListener("click", async () => {
const id = b.getAttribute("data-id");
await apiFetch(`/api/expenses/${id}`, { method: "DELETE" });
await renderExpenses();
});
});
elCostList.querySelectorAll(".cost-edit").forEach((b) => {
b.addEventListener("click", async () => {
const id = b.getAttribute("data-id");
const desc = decodeURIComponent(b.getAttribute("data-desc"));
const jpy = Number(b.getAttribute("data-jpy"));
const eur = Number(b.getAttribute("data-eur"));
const nextDesc = prompt("Descrizione", desc);
if (nextDesc === null) return;
const nextJpyRaw = prompt("Importo JPY", String(jpy));
if (nextJpyRaw === null) return;
const nextJpy = Number(nextJpyRaw);
if (!Number.isFinite(nextJpy) || nextJpy < 0) return;
const nextEur = jpyToEur(nextJpy);
await apiFetch(`/api/expenses/${id}`, {
method: "PATCH",
body: JSON.stringify({ description: nextDesc, jpy: nextJpy, eur: nextEur }),
});
await renderExpenses();
});
});
}
elAddCostBtn.addEventListener("click", async () => {
const description = (elCostDesc.value || "").trim();
const jpyRaw = elCostJpy.value;
const eurRaw = elCostEur.value;
if (!description) return;
const jpyVal = Number(jpyRaw);
const eurVal = Number(eurRaw);
let jpy = 0;
let eur = 0;
if (jpyVal > 0) {
jpy = jpyVal;
eur = jpyToEur(jpyVal);
} else if (eurVal > 0) {
eur = eurVal;
jpy = eurToJpy(eurVal);
} else {
return;
}
await apiFetch("/api/expenses", { method: "POST", body: JSON.stringify({ description, jpy, eur }) });
elCostDesc.value = "";
elCostJpy.value = "";
elCostEur.value = "";
await renderExpenses();
});
(function init() {
document.getElementById("rate").textContent = String(EURJPY);
renderCalendar(DAYS[0].id);
renderDay(DAYS[0].id);
renderTodos();
renderExpenses();
})();