Revamp branding, UI styling, and HAFAS integration
- Update BRAND_GUIDELINES.md with new melting clock logo concept - Rebrand UI components with new violet-to-pink gradient color scheme - Implement HAFAS authentication headers and GET support in route - Update logo SVGs to match new brand guidelines - Fix HAFAS time format to exclude millisecond padding - Update default station to Mödling Bahnhof - Bump workspace package versions to 1.0.0
This commit is contained in:
+23
-15
@@ -2,27 +2,35 @@
|
||||
|
||||
## Logo
|
||||
|
||||
The TimeToLeave logo combines a clock face with an arrow pointing to 10:10 (the classic "watch ad" time), symbolizing "it's time to go."
|
||||
The TimeToLeave logo features a **melting clock** flowing into a right-pointing departure arrow, symbolizing "it's time to go" — time literally dripping away as you head out the door.
|
||||
|
||||
### Meaning
|
||||
- **Clock face** = Time awareness, countdowns, punctuality
|
||||
- **Orange→Yellow arrow** = Departure, leaving, urgency
|
||||
- **Blue→Purple gradient** = Trust, technology, professionalism
|
||||
- **Exit door** (on full icon) = Leaving, heading out
|
||||
- **Melting clock** = Time awareness, urgency, fluidity — like Dali's persistence of memory
|
||||
- **Violet→Magenta gradient** = Creativity, energy, modernity
|
||||
- **Hot pink arrow** (#FF2D8D) = Departure, leaving, forward motion
|
||||
- **Dark background** = Premium, sleek, focused
|
||||
|
||||
## Colors
|
||||
|
||||
### Primary
|
||||
### Primary Gradient
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| System Blue | `#007AFF` | Primary actions, links, active states |
|
||||
| Indigo | `#5856D6` | Gradients, secondary elements |
|
||||
| Violet | `#8B5CF6` | Gradient start |
|
||||
| Magenta | `#B23CFF` | Gradient mid |
|
||||
| Pink | `#D946EF` | Gradient end |
|
||||
| Hot Pink | `#FF2D8D` | Accents, arrows, "To" in wordmark |
|
||||
|
||||
### Accent
|
||||
### Text
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Orange | `#FF9500` | Warnings, countdowns, arrow accents |
|
||||
| Gold | `#FFD60A` | Highlights, urgency indicators |
|
||||
| Off-White | `#F4F1EA` | Primary text on dark backgrounds |
|
||||
|
||||
### Background
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Deep Space | `#03030A` | Outer background |
|
||||
| Night | `#090816` | Inner background |
|
||||
| Twilight | `#17112A` | Highlights, glows |
|
||||
|
||||
### Status Colors (Countdown)
|
||||
| Status | Color | Meaning |
|
||||
@@ -35,8 +43,8 @@ The TimeToLeave logo combines a clock face with an arrow pointing to 10:10 (the
|
||||
|
||||
## Typography
|
||||
|
||||
- **Primary:** System font stack (`system-ui, -apple-system, sans-serif`)
|
||||
- **Weights:** 700 for headings, 400 for body, 300 for captions
|
||||
- **Primary:** Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif
|
||||
- **Weights:** 800 for headlines, 700 for headings, 600 for semibold, 400 for body, 300 for captions
|
||||
- **Letter spacing:** -0.02em for headings (tighter, more modern)
|
||||
|
||||
## Logo Variants
|
||||
@@ -48,7 +56,7 @@ Use `LogoIcon` component for favicons, app icons, loading states.
|
||||
Use `LogoHorizontal` component for headers, navigation, about pages.
|
||||
|
||||
### Full Logo (SVG)
|
||||
Use `assets/logo.svg` for downloads, print, marketing materials.
|
||||
Use `timetoleave_dark_logo.svg` for downloads, print, marketing materials.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -73,4 +81,4 @@ import { LogoIcon, LogoHorizontal } from "@/app/ui/logos";
|
||||
| `apps/web/src/app/ui/LogoHorizontal.tsx` | React horizontal logo |
|
||||
| `apps/web/src/app/icon.svg` | Web favicon (auto-generated by Next.js) |
|
||||
| `apps/web/src/app/opengraph-image.svg` | Social sharing image |
|
||||
| `apps/mobile/assets/logo.svg` | Master SVG for mobile assets |
|
||||
| `apps/web/src/app/timetoleave_dark_logo.svg` | Master SVG with full wordmark |
|
||||
|
||||
+2434
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ['100.107.92.66'],
|
||||
serverExternalPackages: ["node-ical"],
|
||||
env: {
|
||||
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3981.5076039999994,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.31673999999975,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":31.295493999999962,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.02209999999991,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2098.29757,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":58.96350099999995,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":91.7685570000001,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":18.296495999999934,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":71.45395499999995,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":21.57249200000001,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":53.28839500000004,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.105226000000016,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":222.98069600000008,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":70.98355300000003,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":23.987784000000147,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":16.915164000000004,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.030644999999936,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.18994099999986,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":91.62786400000005,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.45920799999999,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4159.340512000001,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":95.16407600000002,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":59.938895999999886,"failed":true}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":12.873146000000133,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2238.9023720000005,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":44.105199000000084,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":98.67802000000006,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":16.264838999999938,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":63.190515000000005,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":23.48364700000002,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":36.42245700000012,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.38964400000009,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":280.1331460000001,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":155.24353599999995,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":20.66457299999979,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":17.434574999999995,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":2.918452000000002,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":231.128872,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":99.0487290000001,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":3.0585660000000416,"failed":false}]]}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@timetoleave/web",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 811 KiB |
@@ -83,12 +83,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full ${className}`}>
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Add Manual Event</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA] mb-6">Add Manual Event</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
Event Title
|
||||
</label>
|
||||
<input
|
||||
@@ -97,14 +97,14 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Team Meeting"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="event-destination"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1"
|
||||
>
|
||||
Destination
|
||||
</label>
|
||||
@@ -114,13 +114,13 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Vienna Main Station"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
@@ -128,12 +128,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="date"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
@@ -141,7 +141,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="time"
|
||||
value={eventTime}
|
||||
onChange={(e) => setEventTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
||||
import { hafasDateTime } from "@timetoleave/core";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
|
||||
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
|
||||
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
|
||||
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
|
||||
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
|
||||
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
|
||||
|
||||
function injectHafasAuth(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
ver: HAFAS_VER,
|
||||
lang: HAFAS_LANG,
|
||||
auth: { type: "AID", aid: HAFAS_AID },
|
||||
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
|
||||
...body,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Validate body shape
|
||||
if (!body || !Array.isArray(body.svcReqL) || body.svcReqL.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
|
||||
const from = searchParams.get('from');
|
||||
const to = searchParams.get('to');
|
||||
const date = searchParams.get('date');
|
||||
|
||||
if (!from || !to || !date) {
|
||||
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
|
||||
}
|
||||
|
||||
const svcReq = body.svcReqL[0];
|
||||
const allowedMethods = ["TripSearch", "LocMatch"];
|
||||
const dateObj = new Date(date);
|
||||
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
|
||||
|
||||
if (
|
||||
!svcReq ||
|
||||
typeof svcReq !== "object" ||
|
||||
typeof svcReq.meth !== "string" ||
|
||||
!allowedMethods.includes(svcReq.meth)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Cap TripSearch results at 10
|
||||
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
|
||||
svcReq.req.numF = 10;
|
||||
}
|
||||
const body = injectHafasAuth({
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "TripSearch",
|
||||
req: {
|
||||
depLocL: [{ type: "S", extId: from }],
|
||||
arrLocL: [{ type: "S", extId: to }],
|
||||
outDate: hafasDate,
|
||||
outTime: hafasTime,
|
||||
numF: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
@@ -61,3 +85,67 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Inject required HAFAS protocol fields (ver, lang, auth.app)
|
||||
// The ÖBB HAFAS gateway rejects requests without these fields
|
||||
const hafasBody = injectHafasAuth(body);
|
||||
|
||||
// Validate body shape (check svcReqL from the enriched body)
|
||||
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const svcReq = hafasBody.svcReqL[0];
|
||||
const allowedMethods = ["TripSearch", "LocMatch"];
|
||||
|
||||
if (
|
||||
!svcReq ||
|
||||
typeof svcReq !== "object" ||
|
||||
typeof svcReq.meth !== "string" ||
|
||||
!allowedMethods.includes(svcReq.meth)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Cap TripSearch results at 10
|
||||
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
|
||||
svcReq.req.numF = 10;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(HAFAS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(hafasBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
||||
}
|
||||
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] HAFAS API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Import Calendar</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Import Calendar</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<div className="border-b border-gray-200 dark:border-white/10 mb-4">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "url" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "url" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
|
||||
onClick={() => setActiveTab("url")}
|
||||
disabled={loading}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "file" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "file" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
|
||||
onClick={() => setActiveTab("file")}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -33,14 +33,14 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-[#F4F1EA]">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
@@ -54,7 +54,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
headers.push(
|
||||
<div key={i} className="text-center font-medium text-gray-600 dark:text-gray-300 py-2">
|
||||
<div key={i} className="text-center font-medium text-gray-600 dark:text-[#F4F1EA]/80 py-2">
|
||||
{daysOfWeek[i]}
|
||||
</div>,
|
||||
);
|
||||
@@ -87,16 +87,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer
|
||||
${!isCurrentMonth ? "bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500" : "bg-white dark:bg-gray-900"}
|
||||
${isTodayDate ? "ring-2 ring-blue-500" : ""}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors
|
||||
min-h-24 p-2 border border-gray-200 dark:border-white/10 rounded-lg cursor-pointer
|
||||
${!isCurrentMonth ? "bg-gray-50 dark:bg-[#0F0E1A] text-gray-400 dark:text-[#F4F1EA]/40" : "bg-white dark:bg-[#17112A]"}
|
||||
${isTodayDate ? "ring-2 ring-[#B23CFF]" : ""}
|
||||
hover:bg-gray-50 dark:hover:bg-white/5 transition-colors
|
||||
`}
|
||||
>
|
||||
<div className="text-right">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
|
||||
isTodayDate ? "bg-blue-500 text-white" : ""
|
||||
isTodayDate ? "bg-[#B23CFF] text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{format(day, "d")}
|
||||
@@ -106,13 +106,13 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="text-xs truncate bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"
|
||||
className="text-xs truncate bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] px-2 py-1 rounded"
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">+{dayEvents.length - 3} more</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
@@ -133,7 +133,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 ${className}`}>
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm p-4 border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
{renderHeader()}
|
||||
{renderDays()}
|
||||
{renderCells()}
|
||||
|
||||
@@ -24,7 +24,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
|
||||
|
||||
if (filteredEvents.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 text-gray-500 dark:text-gray-400 ${className}`}>
|
||||
<div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>
|
||||
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -32,7 +32,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-[#F4F1EA]">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<div className="space-y-3">
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||
|
||||
@@ -64,7 +64,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${dragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${dragging ? "border-[#B23CFF] bg-[#B23CFF]/5" : "border-gray-300 dark:border-white/20"}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -74,7 +74,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
|
||||
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
|
||||
</div>
|
||||
{error && <div className="mt-4 text-red-600 text-sm">{error}</div>}
|
||||
{error && <div className="mt-4 text-[#FF2D8D] text-sm">{error}</div>}
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
|
||||
{loading ? <>Select File</> : <>Select File</>}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
<div className={`p-4 ${className}`}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
Calendar URL
|
||||
</label>
|
||||
<input
|
||||
@@ -33,12 +33,12 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="text-red-600 text-sm">
|
||||
<div role="alert" className="text-[#FF2D8D] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,8 +15,8 @@ export default function CalendarPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Calendar</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-[#F4F1EA]">Calendar</h1>
|
||||
<p className="text-gray-600 dark:text-[#F4F1EA]/80">View and manage your events</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -19,11 +19,11 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden mt-4 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden mt-4 border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Bicycle Route</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Bicycle Route</h3>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -38,29 +38,29 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : bikeError ? (
|
||||
<div className="text-center py-4 text-red-600">Error: {bikeError}</div>
|
||||
<div className="text-center py-4 text-[#FF2D8D]">Error: {bikeError}</div>
|
||||
) : bikeRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-300">Distance</span>
|
||||
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Distance</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-300">Duration</span>
|
||||
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Duration</span>
|
||||
<span className="font-medium">
|
||||
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
||||
</span>
|
||||
</div>
|
||||
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium text-gray-800 dark:text-white mb-2">Steps</h4>
|
||||
<h4 className="font-medium text-gray-800 dark:text-[#F4F1EA] mb-2">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{bikeRoute.steps.map((step, index) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{step.name}</span>
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-300">{step.instruction}</span>
|
||||
<span className="font-medium text-[#B23CFF] dark:text-[#D946EF]">{step.name}</span>
|
||||
<span className="ml-2 text-gray-600 dark:text-[#F4F1EA]/80">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -45,16 +45,16 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
const { countdown, status } = useClock(event.eventTime);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-[#17112A]">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{event.title}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">{event.title}</h3>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="mb-2 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span className="font-medium">Destination:</span> {event.destination}
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ type JourneyListProps = {
|
||||
|
||||
const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, className = "" }) => {
|
||||
if (journeys.length === 0) {
|
||||
return <div className={`text-center py-8 text-gray-500 ${className}`}>No journeys found</div>;
|
||||
return <div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>No journeys found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={`space-y-3 ${className}`}>
|
||||
{journeys.map((journey) => (
|
||||
<li key={journey.id} className="border-b border-gray-200 pb-3 last:border-b-0 last:pb-0">
|
||||
<li key={journey.id} className="border-b border-gray-200 dark:border-white/10 pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{formatTime(journey.sD)}</span>
|
||||
@@ -30,7 +30,7 @@ const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, classNam
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<span className={`font-medium ${journey.cancelled ? "text-red-600" : "text-gray-800"}`}>
|
||||
<span className={`font-medium ${journey.cancelled ? "text-[#FF2D8D]" : "text-gray-800 dark:text-[#F4F1EA]"}`}>
|
||||
{journey.cancelled ? "Cancelled" : journey.trains.join(" → ")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@ const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, classNam
|
||||
<LeaveByBadge countdown={calculateCountdown(eventTime)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -5,11 +5,11 @@ import { CountdownInfo } from "@timetoleave/core";
|
||||
import Chip from "@/app/ui/Chip";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "text-red-600",
|
||||
red: "text-[#FF2D8D]",
|
||||
orange: "text-orange-600",
|
||||
yellow: "text-yellow-600",
|
||||
green: "text-green-600",
|
||||
blue: "text-blue-600",
|
||||
blue: "text-[#B23CFF]",
|
||||
};
|
||||
|
||||
type LeaveByBadgeProps = {
|
||||
|
||||
@@ -27,12 +27,12 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Trains</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Trains</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||
<div className="p-8 text-center text-[#FF2D8D] dark:text-[#FF2D8D] text-sm">{error}</div>
|
||||
) : (
|
||||
<JourneyList journeys={journeys} eventTime={eventTime} />
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function WienerLinienSection({
|
||||
return (
|
||||
<div className={className}>
|
||||
<LoadingSpinner />
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading nearby stops...</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-[#F4F1EA]/60">Loading nearby stops...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function WienerLinienSection({
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<p className="text-sm text-[#FF2D8D] dark:text-[#FF2D8D]">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function WienerLinienSection({
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No nearby stops found.</p>
|
||||
<p className="text-sm text-gray-500 dark:text-[#F4F1EA]/60">No nearby stops found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,17 +66,17 @@ export default function WienerLinienSection({
|
||||
|
||||
return (
|
||||
<div key={stop.id} className="mb-4 last:mb-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{stop.name}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">{stop.name}</h3>
|
||||
|
||||
{stopDepartures.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No departures available</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-[#F4F1EA]/40">No departures available</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{stopDepartures.map((departure, index) => (
|
||||
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
|
||||
<Chip>{departure.lineName}</Chip>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{departure.direction}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<span className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">{departure.direction}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">
|
||||
{departure.minutes} min
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #FAFAF9;
|
||||
--foreground: #1a1a2e;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Arial, Helvetica, sans-serif;
|
||||
--font-sans: "Inter", "Poppins", "Montserrat", "Avenir Next", Arial, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
|
||||
/* Brand palette derived from the TimeToLeave logo */
|
||||
--color-brand-violet: #8B5CF6;
|
||||
--color-brand-purple: #B23CFF;
|
||||
--color-brand-fuchsia: #D946EF;
|
||||
--color-brand-pink: #FF2D8D;
|
||||
--color-brand-dark: #090816;
|
||||
--color-brand-dark-surface: #17112A;
|
||||
--color-brand-light: #F4F1EA;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: #090816;
|
||||
--foreground: #F4F1EA;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Gradient text utility */
|
||||
.brand-gradient-text {
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #B23CFF 40%, #D946EF 72%, #FF2D8D 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
|
||||
|
||||
<g filter="url(#iconGlow)">
|
||||
<!-- Simplified app-icon melting clock -->
|
||||
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
|
||||
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
|
||||
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
|
||||
@@ -39,7 +38,6 @@
|
||||
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
|
||||
</g>
|
||||
|
||||
<!-- Minimal face details for small-size legibility -->
|
||||
<g>
|
||||
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
|
||||
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -7,7 +7,7 @@ import Navbar from "@/app/layout/Navbar";
|
||||
import ReminderEngine from "@/app/layout/ReminderEngine";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
"TimeToLeave — Smart Departure Planner"
|
||||
title: "TimeToLeave — Smart Departure Planner",
|
||||
description:
|
||||
"Plan your train journeys, sync with your calendar, and know exactly when to leave for your appointments.",
|
||||
icons: {
|
||||
@@ -23,7 +23,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col bg-[#FAFAF9] dark:bg-[#090816] text-[#1a1a2e] dark:text-[#F4F1EA]">
|
||||
<ReminderSettingsProvider>
|
||||
<EventsProvider>
|
||||
<ReminderEngine />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useServerHealth } from "@/hooks/useServerHealth";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import Link from "next/link";
|
||||
import LogoHorizontal from "@/app/ui/LogoHorizontal";
|
||||
import Image from "next/image";
|
||||
|
||||
type HeaderProps = {
|
||||
className?: string;
|
||||
@@ -19,43 +19,50 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const { status } = useServerHealth();
|
||||
const { events } = useEventsStore();
|
||||
const { dark, toggle } = useTheme();
|
||||
const { dark, toggle, mounted } = useTheme();
|
||||
|
||||
return (
|
||||
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
|
||||
<header className={`bg-white dark:bg-[#0F0E1A] border-b border-gray-200 dark:border-white/10 shadow-sm ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/">
|
||||
<LogoHorizontal height={32} />
|
||||
<Image
|
||||
src="/timetoleave_logo_text_right.png"
|
||||
alt="TimeToLeave"
|
||||
width={192}
|
||||
height={48}
|
||||
className="object-cover h-12 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span>
|
||||
<strong>{events.length}</strong>
|
||||
</span>
|
||||
<span className="ml-1">events</span>
|
||||
{status === true ? (
|
||||
<span className="ml-2 text-green-600">Online</span>
|
||||
<span className="ml-2 text-green-500">Online</span>
|
||||
) : status === false ? (
|
||||
<span className="ml-2 text-red-600">Offline</span>
|
||||
<span className="ml-2 text-red-500">Offline</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowAddEventModal(true)}>
|
||||
Add Event
|
||||
</Button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Toggle dark mode"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
|
||||
>
|
||||
{dark ? "☀️" : "🌙"}
|
||||
{mounted ? (dark ? "☀️" : "🌙") : "🌙"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
@@ -64,16 +71,16 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
</div>
|
||||
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
||||
{showSettingsModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-sm relative">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-[#17112A] rounded-lg shadow-lg p-6 w-full max-w-sm relative border border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(false)}
|
||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg"
|
||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-[#F4F1EA] text-lg"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Settings</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-[#F4F1EA]">Settings</h2>
|
||||
<ReminderSettingsPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className={`bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
<nav className={`bg-white dark:bg-[#0F0E1A] border-t border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex space-x-8">
|
||||
@@ -25,7 +25,11 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-sm font-medium ${pathname === link.href ? "text-blue-600 border-b-2 border-blue-600" : "text-gray-500 hover:text-gray-700 hover:border-gray-300"}`}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
pathname === link.href
|
||||
? "text-[#B23CFF] border-b-2 border-[#B23CFF]"
|
||||
: "text-gray-500 hover:text-[#D946EF] hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#007AFF" />
|
||||
<stop offset="100%" stop-color="#5856D6" />
|
||||
</linearGradient>
|
||||
<linearGradient id="arrowGrad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FF9500" />
|
||||
<stop offset="100%" stop-color="#FFD60A" />
|
||||
<radialGradient id="ogBg" cx="50%" cy="40%" r="80%">
|
||||
<stop offset="0%" stop-color="#17112A"/>
|
||||
<stop offset="55%" stop-color="#090816"/>
|
||||
<stop offset="100%" stop-color="#03030A"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="ogMelt" x1="100" y1="50" x2="700" y2="550" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#8B5CF6"/>
|
||||
<stop offset="40%" stop-color="#B23CFF"/>
|
||||
<stop offset="72%" stop-color="#D946EF"/>
|
||||
<stop offset="100%" stop-color="#FF2D8D"/>
|
||||
</linearGradient>
|
||||
<filter id="ogGlow" x="-25%" y="-25%" width="150%" height="150%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bgGrad)" rx="24" />
|
||||
<rect width="1200" height="630" fill="url(#ogBg)"/>
|
||||
|
||||
<!-- Decorative clock circles -->
|
||||
<circle cx="200" cy="100" r="80" fill="rgba(255,255,255,0.05)" />
|
||||
<circle cx="1050" cy="550" r="120" fill="rgba(255,255,255,0.05)" />
|
||||
<circle cx="100" cy="500" r="60" fill="rgba(255,255,255,0.03)" />
|
||||
|
||||
<!-- Large icon -->
|
||||
<g transform="translate(180, 215) scale(1.3)">
|
||||
<circle cx="100" cy="100" r="80" fill="rgba(255,255,255,0.15)" />
|
||||
<g stroke="rgba(255,255,255,0.4)" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="100" y1="25" x2="100" y2="32" />
|
||||
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(60 100 100)" />
|
||||
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(120 100 100)" />
|
||||
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(180 100 100)" />
|
||||
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(240 100 100)" />
|
||||
<line x1="100" y1="25" x2="100" y2="32" transform="rotate(300 100 100)" />
|
||||
</g>
|
||||
<line x1="100" y1="100" x2="65" y2="55" stroke="rgba(255,255,255,0.7)" stroke-width="4" stroke-linecap="round" />
|
||||
<g transform="translate(100,100) rotate(-45)">
|
||||
<line x1="0" y1="0" x2="0" y2="-45" stroke="url(#arrowGrad)" stroke-width="5" stroke-linecap="round" />
|
||||
<polygon points="-9,-38 0,-52 9,-38" fill="url(#arrowGrad)" />
|
||||
</g>
|
||||
<circle cx="100" cy="100" r="5" fill="white" />
|
||||
<circle cx="100" cy="100" r="2.5" fill="rgba(0,122,255,0.8)" />
|
||||
|
||||
{/* Fire-exit running person at date-window position */}
|
||||
<g transform="translate(100, 148) scale(0.35)">
|
||||
<rect x="14" y="-30" width="24" height="60" rx="3" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="5" />
|
||||
<g stroke="rgba(255,255,255,0.65)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="-10" cy="-20" r="6" fill="rgba(255,255,255,0.65)" stroke="none" />
|
||||
<line x1="-10" y1="-14" x2="-10" y2="2" />
|
||||
<line x1="-10" y1="-9" x2="-20" y2="-15" />
|
||||
<line x1="-10" y1="-9" x2="-2" y2="-3" />
|
||||
<line x1="-10" y1="2" x2="-22" y2="14" />
|
||||
<line x1="-10" y1="2" x2="-2" y2="12" />
|
||||
</g>
|
||||
</g>
|
||||
<!-- Melting clock icon -->
|
||||
<g transform="translate(180, 130) scale(0.9)" filter="url(#ogGlow)">
|
||||
<path d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
|
||||
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
|
||||
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
|
||||
<path d="M460,465 C480,450 500,435 520,425"
|
||||
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
|
||||
<path d="M500,400 L540,420 L490,455 L500,430 Z" fill="#FF2D8D"/>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="440" y="300" fill="white" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
|
||||
<!-- Clock ticks -->
|
||||
<g stroke="#F4F1EA" stroke-width="8" stroke-linecap="round" opacity="0.96" transform="translate(180, 130) scale(0.9)">
|
||||
<line x1="290" y1="180" x2="290" y2="200"/>
|
||||
<line x1="370" y1="220" x2="385" y2="210"/>
|
||||
<line x1="400" y1="310" x2="420" y2="310"/>
|
||||
<line x1="290" y1="400" x2="290" y2="420"/>
|
||||
<line x1="200" y1="390" x2="215" y2="375"/>
|
||||
<line x1="170" y1="310" x2="190" y2="310"/>
|
||||
<line x1="200" y1="230" x2="215" y2="245"/>
|
||||
</g>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<g stroke="#F4F1EA" stroke-width="12" stroke-linecap="round" transform="translate(180, 130) scale(0.9)">
|
||||
<line x1="290" y1="310" x2="290" y2="230"/>
|
||||
<line x1="290" y1="310" x2="360" y2="355"/>
|
||||
</g>
|
||||
<circle cx="442" cy="413" r="14" fill="#F4F1EA" transform="scale(0.9) translate(53, 45)"/>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<text x="520" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
|
||||
Time
|
||||
</text>
|
||||
<text x="660" y="300" fill="#FFD60A" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
|
||||
<text x="740" y="290" fill="#FF2D8D" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
|
||||
To
|
||||
</text>
|
||||
<text x="780" y="300" fill="white" font-size="80" font-weight="800" font-family="system-ui, -apple-system, sans-serif" letter-spacing="-2">
|
||||
<text x="860" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
|
||||
Leave
|
||||
</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="440" y="380" fill="rgba(255,255,255,0.8)" font-size="32" font-weight="400" font-family="system-ui, -apple-system, sans-serif">
|
||||
<text x="520" y="370" fill="rgba(244,241,234,0.7)" font-size="32" font-weight="400" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
|
||||
Smart Departure Planner
|
||||
</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="440" y="440" fill="rgba(255,255,255,0.5)" font-size="22" font-weight="300" font-family="system-ui, -apple-system, sans-serif">
|
||||
<text x="520" y="430" fill="rgba(244,241,234,0.4)" font-size="22" font-weight="300" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
|
||||
Never miss your train again. ⏱️ 🚆 🚲
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -15,9 +15,9 @@ export default function Home() {
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto w-full px-4 py-6">
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500 dark:text-gray-400">
|
||||
<p className="text-lg font-medium">No upcoming events</p>
|
||||
<p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
|
||||
<div className="text-center py-16 text-gray-500 dark:text-[#F4F1EA]/60">
|
||||
<p className="text-2xl font-bold brand-gradient-text mb-2">No upcoming events</p>
|
||||
<p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import React from "react";
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
variant?: "primary" | "secondary" | "accent" | "danger";
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ children, className = "", variant = "primary", size = "md", ...props }) => {
|
||||
const baseStyles = "font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
|
||||
const variantStyles = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
||||
primary:
|
||||
"bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white hover:opacity-90 focus:ring-brand-purple",
|
||||
secondary:
|
||||
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-500",
|
||||
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-white/10 dark:text-[#F4F1EA] dark:hover:bg-white/20 dark:focus:ring-brand-purple",
|
||||
accent:
|
||||
"bg-[#FF2D8D] text-white hover:bg-[#E5247D] focus:ring-brand-pink",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||
};
|
||||
const sizeStyles = {
|
||||
|
||||
@@ -10,7 +10,7 @@ type ChipProps = {
|
||||
const Chip: React.FC<ChipProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<span className={
|
||||
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 ${className}`
|
||||
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] ${className}`
|
||||
}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ const colorMap: Record<string, string> = {
|
||||
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
|
||||
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
|
||||
green: "text-green-600 bg-green-50 dark:bg-green-900/30",
|
||||
blue: "text-blue-600 bg-blue-50 dark:bg-blue-900/30",
|
||||
blue: "text-[#B23CFF] bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20",
|
||||
};
|
||||
|
||||
type CountdownBadgeProps = {
|
||||
|
||||
@@ -19,7 +19,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
|
||||
return (
|
||||
<div className={
|
||||
`inline-block animate-spin rounded-full border-4 border-solid border-blue-500 border-t-transparent ${sizeClasses[size]} ${className}`
|
||||
`inline-block animate-spin rounded-full border-4 border-solid border-[#B23CFF] border-t-transparent ${sizeClasses[size]} ${className}`
|
||||
}>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
@@ -16,92 +16,96 @@ export default function LogoHorizontal({
|
||||
aria-label="TimeToLeave"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ttt-h-bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#007AFF" />
|
||||
<stop offset="100%" stopColor="#5856D6" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="ttt-h-arrowGrad"
|
||||
id="ttt-h-meltGrad"
|
||||
x1="0%"
|
||||
y1="100%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#FF9500" />
|
||||
<stop offset="100%" stopColor="#FFD60A" />
|
||||
<stop offset="0%" stopColor="#8B5CF6" />
|
||||
<stop offset="40%" stopColor="#B23CFF" />
|
||||
<stop offset="72%" stopColor="#D946EF" />
|
||||
<stop offset="100%" stopColor="#FF2D8D" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Icon on the left */}
|
||||
<g transform={`scale(${height / 48}) translate(0, ${height * 0.1})`}>
|
||||
<circle cx={24} cy={24} r={20} fill="url(#ttt-h-bgGrad)" />
|
||||
<g
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth="2"
|
||||
{/* Melting clock icon on the left */}
|
||||
<g transform={`scale(${height / 48}) translate(0, ${height * 0.05})`}>
|
||||
{/* Clock outline — melting arc */}
|
||||
<path
|
||||
d="M20,36 C19,32 20,27 22,23 C25,18 30,17 34,17 C40,17 44,21 45,27 C45,30 45,32 44,33 C44,34 44,35 46,36 C47,36 47,34 48,33 C49,33 49,35 48,36 C47,37 46,37 45,37"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
{[0, 60, 120, 180, 240, 300].map((angle, i) => (
|
||||
<line
|
||||
key={angle}
|
||||
x1={24}
|
||||
y1={7}
|
||||
x2={24}
|
||||
y2={9}
|
||||
transform={`rotate(${angle} 24 24)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<g transform="translate(24,24) rotate(-45)">
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={0}
|
||||
y2={-10}
|
||||
stroke="url(#ttt-h-arrowGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon
|
||||
points="-4,-8 0,-12 4,-8"
|
||||
fill="url(#ttt-h-arrowGrad)"
|
||||
/>
|
||||
</g>
|
||||
<circle cx={24} cy={24} r={2} fill="white" />
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Melting drip */}
|
||||
<path
|
||||
d="M30,37 C30,39 30,41 31,41 C32,41 32,39 32,37"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Flowing arrow to the right */}
|
||||
<path
|
||||
d="M45,33 C47,32 49,31 51,31"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M49,29 L54,31 L49,33 Z"
|
||||
fill="#FF2D8D"
|
||||
/>
|
||||
{/* Clock ticks */}
|
||||
<line x1="32" y1="19" x2="32" y2="20.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="42" y1="23" x2="41" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="44" y1="30" x2="42.5" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="32" y1="35" x2="32" y2="33.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="22" y1="31" x2="23" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="20" y1="24" x2="21.5" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
{/* Clock hands */}
|
||||
<line x1="32" y1="27" x2="32" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
|
||||
<line x1="32" y1="27" x2="37" y2="30" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
|
||||
<circle cx="32" cy="27" r="1.2" fill="currentColor" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
{/* Text on the right */}
|
||||
{/* Wordmark on the right */}
|
||||
<text
|
||||
x={height * 0.75}
|
||||
x={height * 0.7}
|
||||
y={height * 0.65}
|
||||
fill="currentColor"
|
||||
className="dark:fill-white"
|
||||
className="dark:fill-[#F4F1EA]"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
Time
|
||||
</text>
|
||||
<text
|
||||
x={height * 0.75 + height * 1.15}
|
||||
x={height * 0.7 + height * 1.0}
|
||||
y={height * 0.65}
|
||||
fill="#007AFF"
|
||||
className="dark:fill-[#5AC8FA]"
|
||||
fill="url(#ttt-h-meltGrad)"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
To
|
||||
</text>
|
||||
<text
|
||||
x={height * 0.75 + height * 1.75}
|
||||
x={height * 0.7 + height * 1.55}
|
||||
y={height * 0.65}
|
||||
fill="currentColor"
|
||||
className="dark:fill-white"
|
||||
className="dark:fill-[#F4F1EA]"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
Leave
|
||||
|
||||
@@ -15,109 +15,70 @@ export default function LogoIcon({
|
||||
aria-label="TimeToLeave logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ttt-bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#007AFF" />
|
||||
<stop offset="100%" stopColor="#5856D6" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ttt-arrowGrad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FF9500" />
|
||||
<stop offset="100%" stopColor="#FFD60A" />
|
||||
<linearGradient
|
||||
id="ttt-iconMelt"
|
||||
x1="100"
|
||||
y1="80"
|
||||
x2="420"
|
||||
y2="430"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#8B5CF6" />
|
||||
<stop offset="40%" stopColor="#B23CFF" />
|
||||
<stop offset="72%" stopColor="#D946EF" />
|
||||
<stop offset="100%" stopColor="#FF2D8D" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="256" cy="256" r="240" fill="url(#ttt-bgGrad)" />
|
||||
|
||||
<g stroke="rgba(255,255,255,0.3)" strokeWidth="8" strokeLinecap="round">
|
||||
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map(
|
||||
(angle, i) => (
|
||||
<line
|
||||
key={angle}
|
||||
x1={256}
|
||||
y1={40}
|
||||
x2={256}
|
||||
y2={i % 3 === 0 ? 60 : 52}
|
||||
transform={`rotate(${angle} 256 256)`}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</g>
|
||||
|
||||
<line
|
||||
x1={256}
|
||||
y1={256}
|
||||
x2={168}
|
||||
y2={140}
|
||||
stroke="rgba(255,255,255,0.6)"
|
||||
strokeWidth={10}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
<g transform="translate(256,256) rotate(-45)">
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={0}
|
||||
y2={-120}
|
||||
stroke="url(#ttt-arrowGrad)"
|
||||
strokeWidth={14}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon points="-22,-100 0,-145 22,-100" fill="url(#ttt-arrowGrad)" />
|
||||
</g>
|
||||
|
||||
<circle cx={256} cy={256} r={12} fill="white" />
|
||||
<circle cx={256} cy={256} r={6} fill="url(#ttt-bgGrad)" />
|
||||
|
||||
{/* Fire-exit: running person through open door */}
|
||||
<g transform="translate(256, 395) scale(0.7)">
|
||||
<rect
|
||||
x={14}
|
||||
y={-30}
|
||||
width={24}
|
||||
height={60}
|
||||
rx={3}
|
||||
{/* Melting clock outline */}
|
||||
<g>
|
||||
<path
|
||||
d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.65)"
|
||||
strokeWidth={3.5}
|
||||
/>
|
||||
<rect
|
||||
x={30}
|
||||
y={-27}
|
||||
width={10}
|
||||
height={54}
|
||||
rx={2}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<line
|
||||
x1={30}
|
||||
y1={-27}
|
||||
x2={30}
|
||||
y2={27}
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<g
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
strokeWidth={3}
|
||||
stroke="url(#ttt-iconMelt)"
|
||||
strokeWidth="28"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx={-10}
|
||||
cy={-20}
|
||||
r={6}
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
stroke="none"
|
||||
/>
|
||||
<line x1={-10} y1={-14} x2={-10} y2={2} />
|
||||
<line x1={-10} y1={-9} x2={-20} y2={-15} />
|
||||
<line x1={-10} y1={-9} x2={-2} y2={-3} />
|
||||
<line x1={-10} y1={2} x2={-22} y2={14} />
|
||||
<line x1={-10} y1={2} x2={-2} y2={12} />
|
||||
</g>
|
||||
/>
|
||||
{/* Melting drip */}
|
||||
<path
|
||||
d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
|
||||
fill="none"
|
||||
stroke="url(#ttt-iconMelt)"
|
||||
strokeWidth="28"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Flowing arrow */}
|
||||
<path
|
||||
d="M460,465 C480,450 500,435 520,425"
|
||||
fill="none"
|
||||
stroke="url(#ttt-iconMelt)"
|
||||
strokeWidth="28"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M500,400 L540,420 L490,455 L500,430 Z"
|
||||
fill="#FF2D8D"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Clock ticks */}
|
||||
<g stroke="#F4F1EA" strokeWidth="10" strokeLinecap="round" opacity="0.96">
|
||||
<line x1="290" y1="180" x2="290" y2="200" />
|
||||
<line x1="370" y1="220" x2="385" y2="210" />
|
||||
<line x1="400" y1="310" x2="420" y2="310" />
|
||||
<line x1="290" y1="400" x2="290" y2="420" />
|
||||
<line x1="200" y1="390" x2="215" y2="375" />
|
||||
<line x1="170" y1="310" x2="190" y2="310" />
|
||||
<line x1="200" y1="230" x2="215" y2="245" />
|
||||
</g>
|
||||
|
||||
{/* Clock hands — 10:10 */}
|
||||
<g stroke="#F4F1EA" strokeWidth="14" strokeLinecap="round">
|
||||
<line x1="290" y1="310" x2="290" y2="230" />
|
||||
<line x1="290" y1="310" x2="360" y2="355" />
|
||||
</g>
|
||||
<circle cx="290" cy="310" r="16" fill="#F4F1EA" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
Leave reminders
|
||||
</span>
|
||||
<button
|
||||
@@ -34,7 +34,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
|
||||
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -50,7 +50,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
Remind me{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
@@ -66,11 +66,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
step={1}
|
||||
value={bufferMinutes}
|
||||
onChange={(e) => setBufferMinutes(Number(e.target.value))}
|
||||
className="flex-1 accent-blue-600"
|
||||
className="flex-1 accent-[#B23CFF]"
|
||||
/>
|
||||
<output
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-gray-200"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
{bufferMinutes}
|
||||
</output>
|
||||
@@ -79,12 +79,12 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
)}
|
||||
|
||||
{/* Permission status */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-white/10">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
|
||||
{permission !== "granted" && permission !== "denied" && (
|
||||
<button
|
||||
onClick={() => Notification.requestPermission()}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||
className="mt-2 text-xs text-[#B23CFF] hover:underline"
|
||||
>
|
||||
Request permission now
|
||||
</button>
|
||||
|
||||
@@ -13,12 +13,12 @@ function makeHafasSuccessData() {
|
||||
ctxRecon: "journey-1",
|
||||
secL: [
|
||||
{
|
||||
dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" },
|
||||
prod: { name: "Railjet 123" },
|
||||
dep: { dTimeS: "120000", dTimeR: "120000" },
|
||||
jny: { stopL: [{ name: "RJX 123" }] },
|
||||
},
|
||||
{
|
||||
arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" },
|
||||
prod: { name: "Railjet 123" },
|
||||
arr: { aTimeS: "130000", aTimeR: "130000" },
|
||||
jny: { stopL: [{ name: "RJX 123" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ interface HafasLocation {
|
||||
type: string;
|
||||
name: string;
|
||||
extId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export function useDestinationStation(destination: string) {
|
||||
@@ -18,33 +20,54 @@ export function useDestinationStation(destination: string) {
|
||||
useEffect(() => {
|
||||
if (!destination.trim()) return;
|
||||
let isMounted = true;
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const geocodeResults = await client.geocode(destination, "at");
|
||||
const coords = geocodeResults[0];
|
||||
if (!coords) {
|
||||
if (isMounted) {
|
||||
setStation(null);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "LocMatch",
|
||||
req: { input: { loc: { name: destination, type: "S" }, maxLoc: 1 } },
|
||||
req: {
|
||||
input: {
|
||||
loc: {
|
||||
crd: {
|
||||
x: Math.round(coords.lng * 1e6),
|
||||
y: Math.round(coords.lat * 1e6),
|
||||
},
|
||||
type: "S",
|
||||
},
|
||||
maxLoc: 1,
|
||||
field: "S",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await client.hafasRequest<any>(body);
|
||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||
const stations = locL.filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
|
||||
const stations = locL
|
||||
.filter((l) => l.type === "S")
|
||||
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
|
||||
|
||||
if (!isMounted) return;
|
||||
setStation(stations[0] ?? null);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : "Station lookup failed");
|
||||
setLoading(false);
|
||||
@@ -55,7 +78,6 @@ export function useDestinationStation(destination: string) {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(timeoutId);
|
||||
abortController?.abort();
|
||||
};
|
||||
}, [destination]);
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export function useOriginStation() {
|
||||
try {
|
||||
const { latitude, longitude } = location.coords;
|
||||
|
||||
// Use HAFAS LocMatch with coordinates to find nearest station
|
||||
const body = {
|
||||
svcReqL: [
|
||||
{
|
||||
@@ -42,11 +41,14 @@ export function useOriginStation() {
|
||||
req: {
|
||||
input: {
|
||||
loc: {
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
crd: {
|
||||
x: Math.round(longitude * 1e6),
|
||||
y: Math.round(latitude * 1e6),
|
||||
},
|
||||
type: "S",
|
||||
},
|
||||
maxLoc: 5,
|
||||
field: "S",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const THEME_KEY = "ttl_theme";
|
||||
|
||||
@@ -17,9 +17,15 @@ function getClientTheme(): "dark" | "light" {
|
||||
|
||||
export function useTheme() {
|
||||
const [dark, setDark] = useState(() => getClientTheme() === "dark");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
setMounted(true);
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}
|
||||
}, [dark]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
@@ -30,5 +36,5 @@ export function useTheme() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { dark, toggle };
|
||||
return { dark, toggle, mounted };
|
||||
}
|
||||
|
||||
@@ -199,63 +199,63 @@ describe("hafasDateTime", () => {
|
||||
it("roundtrips standard CET times", () => {
|
||||
expect(roundtrip("20240115", "080000")).toEqual({
|
||||
date: "20240115",
|
||||
time: "080000000",
|
||||
time: "080000",
|
||||
});
|
||||
expect(roundtrip("20240201", "000000")).toEqual({
|
||||
date: "20240201",
|
||||
time: "000000000",
|
||||
time: "000000",
|
||||
});
|
||||
expect(roundtrip("20241231", "235900")).toEqual({
|
||||
date: "20241231",
|
||||
time: "235900000",
|
||||
time: "235900",
|
||||
});
|
||||
});
|
||||
|
||||
it("roundtrips standard CEST times", () => {
|
||||
expect(roundtrip("20240715", "080000")).toEqual({
|
||||
date: "20240715",
|
||||
time: "080000000",
|
||||
time: "080000",
|
||||
});
|
||||
expect(roundtrip("20240801", "000000")).toEqual({
|
||||
date: "20240801",
|
||||
time: "000000000",
|
||||
time: "000000",
|
||||
});
|
||||
});
|
||||
|
||||
it("roundtrips spring DST transition (Mar 31, 2024)", () => {
|
||||
expect(roundtrip("20240331", "010000")).toEqual({
|
||||
date: "20240331",
|
||||
time: "010000000",
|
||||
time: "010000",
|
||||
});
|
||||
expect(roundtrip("20240331", "030000")).toEqual({
|
||||
date: "20240331",
|
||||
time: "030000000",
|
||||
time: "030000",
|
||||
});
|
||||
expect(roundtrip("20240331", "120000")).toEqual({
|
||||
date: "20240331",
|
||||
time: "120000000",
|
||||
time: "120000",
|
||||
});
|
||||
});
|
||||
|
||||
it("roundtrips fall DST transition (Oct 27, 2024)", () => {
|
||||
expect(roundtrip("20241027", "000000")).toEqual({
|
||||
date: "20241027",
|
||||
time: "000000000",
|
||||
time: "000000",
|
||||
});
|
||||
expect(roundtrip("20241027", "023000")).toEqual({
|
||||
date: "20241027",
|
||||
time: "023000000",
|
||||
time: "023000",
|
||||
});
|
||||
expect(roundtrip("20241027", "120000")).toEqual({
|
||||
date: "20241027",
|
||||
time: "120000000",
|
||||
time: "120000",
|
||||
});
|
||||
});
|
||||
|
||||
it("roundtrips leap year", () => {
|
||||
expect(roundtrip("20240229", "120000")).toEqual({
|
||||
date: "20240229",
|
||||
time: "120000000",
|
||||
time: "120000",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,7 @@ describe("hafasDateTime", () => {
|
||||
const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0));
|
||||
const { date, time } = hafasDateTime(utc);
|
||||
expect(date).toBe("20240115");
|
||||
expect(time).toBe("080000000");
|
||||
expect(time).toBe("080000");
|
||||
});
|
||||
|
||||
it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => {
|
||||
@@ -274,7 +274,7 @@ describe("hafasDateTime", () => {
|
||||
const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0));
|
||||
const { date, time } = hafasDateTime(utc);
|
||||
expect(date).toBe("20240715");
|
||||
expect(time).toBe("080000000");
|
||||
expect(time).toBe("080000");
|
||||
});
|
||||
|
||||
it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => {
|
||||
@@ -282,7 +282,7 @@ describe("hafasDateTime", () => {
|
||||
const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0));
|
||||
const { date, time } = hafasDateTime(utc);
|
||||
expect(date).toBe("20240115");
|
||||
expect(time).toBe("000000000");
|
||||
expect(time).toBe("000000");
|
||||
});
|
||||
|
||||
it('produces "00" for midnight, not "24" (k-clock safety)', () => {
|
||||
@@ -291,7 +291,7 @@ describe("hafasDateTime", () => {
|
||||
// Dec 31 23:00 UTC = Jan 1 00:00 Vienna (CET)
|
||||
const midnightUtc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
|
||||
const { time: midnightTime } = hafasDateTime(midnightUtc);
|
||||
expect(midnightTime).toBe("000000000");
|
||||
expect(midnightTime).toBe("000000");
|
||||
});
|
||||
|
||||
it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => {
|
||||
@@ -299,7 +299,7 @@ describe("hafasDateTime", () => {
|
||||
const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
|
||||
const { date, time } = hafasDateTime(utc);
|
||||
expect(date).toBe("20240101");
|
||||
expect(time).toBe("000000000");
|
||||
expect(time).toBe("000000");
|
||||
});
|
||||
|
||||
it("pads single-digit components correctly", () => {
|
||||
@@ -307,7 +307,7 @@ describe("hafasDateTime", () => {
|
||||
const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7));
|
||||
const { date, time } = hafasDateTime(utc);
|
||||
expect(date).toBe("20240102");
|
||||
expect(time).toBe("080307000");
|
||||
expect(time).toBe("080307");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,6 @@ export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToL
|
||||
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
|
||||
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
|
||||
export const DEFAULT_DAYS = 14;
|
||||
export const DEFAULT_STATION_NAME = "Wiener Hauptbahnhof";
|
||||
export const DEFAULT_STATION_EXT_ID = "wik9000001";
|
||||
export const DEFAULT_STATION_NAME = "Mödling Bahnhof";
|
||||
export const DEFAULT_STATION_EXT_ID = "1231701";
|
||||
export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
|
||||
|
||||
@@ -118,7 +118,7 @@ export class HafasClient {
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "LocMatch",
|
||||
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5 } },
|
||||
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5, field: "S" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "time-to-leave",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "time-to-leave",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@timetoleave/web",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
@@ -19367,7 +19367,7 @@
|
||||
},
|
||||
"packages/api-client": {
|
||||
"name": "@timetoleave/api-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@timetoleave/core": "*"
|
||||
},
|
||||
@@ -19377,7 +19377,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@timetoleave/core",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "time-to-leave",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@timetoleave/api-client",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -35,7 +35,8 @@ export class ApiClient {
|
||||
const url = buildUrl(this.baseUrl, "/api/geocode", params);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
|
||||
return res.json();
|
||||
const result: GeocodeResult = await res.json();
|
||||
return [result];
|
||||
}
|
||||
|
||||
async getBikeRoute(
|
||||
@@ -93,12 +94,19 @@ export class ApiClient {
|
||||
toStationExtId: string,
|
||||
date: Date,
|
||||
): Promise<Journey[]> {
|
||||
const url = buildUrl(this.baseUrl, "/api/hafas", {
|
||||
// Use POST request as per the new implementation
|
||||
const body = {
|
||||
from: fromStationExtId,
|
||||
to: toStationExtId,
|
||||
date: date.toISOString(),
|
||||
};
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/hafas`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@timetoleave/core",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -110,6 +110,6 @@ export function hafasDateTime(date: Date): { date: string; time: string } {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const p = getDateTimeParts(date, "Europe/Vienna");
|
||||
const d = `${p.year}${pad(p.month)}${pad(p.day)}`;
|
||||
const t = `${pad(p.hour)}${pad(p.minute)}${pad(p.second)}000`;
|
||||
const t = `${pad(p.hour)}${pad(p.minute)}${pad(p.second)}`;
|
||||
return { date: d, time: t };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user