mobile app phase 1
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@timetoleave/api-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@timetoleave/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
GeocodeResult,
|
||||
BikeRoute,
|
||||
CalendarEvent,
|
||||
Journey,
|
||||
Station,
|
||||
} from "@timetoleave/core";
|
||||
|
||||
const DEFAULT_BASE_URL = "";
|
||||
|
||||
function buildUrl(base: string, path: string, params: Record<string, string> = {}): string {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
const full = `${base}${path}`;
|
||||
return queryString ? `${full}?${queryString}` : full;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
async getHealth(): Promise<{ status: "ok"; uptime: number }> {
|
||||
const res = await fetch(`${this.baseUrl}/api/health`);
|
||||
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> {
|
||||
const params: Record<string, string> = { name };
|
||||
if (countrycodes) params.countrycodes = countrycodes;
|
||||
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();
|
||||
}
|
||||
|
||||
async getBikeRoute(
|
||||
fromLat: number,
|
||||
fromLng: number,
|
||||
toLat: number,
|
||||
toLng: number,
|
||||
): Promise<BikeRoute> {
|
||||
const url = buildUrl(this.baseUrl, "/api/bike-route", {
|
||||
fromLat: String(fromLat),
|
||||
fromLng: String(fromLng),
|
||||
toLat: String(toLat),
|
||||
toLng: String(toLng),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Bike route failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
|
||||
const params: Record<string, string> = { url };
|
||||
if (days) params.days = String(days);
|
||||
const api = buildUrl(this.baseUrl, "/api/calendar", params);
|
||||
const res = await fetch(api);
|
||||
if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async parseCalendarIcs(content: string): Promise<CalendarEvent[]> {
|
||||
const res = await fetch(`${this.baseUrl}/api/calendar/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/calendar" },
|
||||
body: content,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Calendar parse failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async searchJourneys(
|
||||
fromStationExtId: string,
|
||||
toStationExtId: string,
|
||||
date: Date,
|
||||
): Promise<Journey[]> {
|
||||
const url = buildUrl(this.baseUrl, "/api/hafas", {
|
||||
from: fromStationExtId,
|
||||
to: toStationExtId,
|
||||
date: date.toISOString(),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async searchStation(query: string): Promise<Station[]> {
|
||||
const url = buildUrl(this.baseUrl, "/api/station", { q: query });
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Station search failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ApiClient } from './client';
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@timetoleave/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Countdown utilities for TimeToLeave
|
||||
import { CountdownInfo } from "./types";
|
||||
|
||||
export function calculateCountdown(targetDate: Date): CountdownInfo {
|
||||
const now = new Date();
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin <= 0) {
|
||||
return {
|
||||
label: "Now",
|
||||
color: "red",
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 10) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "orange",
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 30) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "yellow",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (diffMin <= 60) {
|
||||
return {
|
||||
label: `${diffMin}min`,
|
||||
color: "green",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hours = Math.floor(diffMin / 60);
|
||||
const remainingMin = diffMin % 60;
|
||||
return {
|
||||
label: `${hours}h ${remainingMin}min`,
|
||||
color: "blue",
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Formatting utilities for TimeToLeave
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString("de-AT", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date): string {
|
||||
return date.toLocaleString("de-AT", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}min`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${meters}m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)}km`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Timezone-aware parsing for HAFAS times.
|
||||
// HAFAS returns times in Vienna (CET/CEST). This module provides correct parsing
|
||||
// regardless of the server or browser timezone.
|
||||
|
||||
// Helper: get the offset (in minutes) from UTC for a given instant in a specific
|
||||
// IANA timezone.
|
||||
//
|
||||
// CAVEAT — Hour-level precision only. This helper reads the hour via
|
||||
// Intl.DateTimeFormat (hour part only), so it returns incorrect results for
|
||||
// timezones with fractional-hour offsets (e.g. India +05:30, Nepal +05:45).
|
||||
// It is designed exclusively for "Europe/Vienna" (CET/CEST), which only uses
|
||||
// full-hour offsets (+01:00 / +02:00). Do not reuse for other timezones.
|
||||
function getTimezoneOffsetMinutes(instant: Date, tz: string): number {
|
||||
const getHour = (timeZone: string) => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(instant);
|
||||
return parseInt(parts.find((p) => p.type === "hour")!.value, 10);
|
||||
};
|
||||
|
||||
const utcH = getHour("UTC");
|
||||
const tzH = getHour(tz);
|
||||
|
||||
let diff = tzH - utcH;
|
||||
// Handle midnight wraparound:
|
||||
// e.g. UTC 23 → Vienna 01: diff = -22, but actual offset is +2.
|
||||
// Vienna is always between +1 and +2, so |diff| > 12 means we crossed midnight.
|
||||
if (diff < -12) diff += 24;
|
||||
if (diff > 12) diff -= 24;
|
||||
|
||||
return diff * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a HAFAS date+time string into a proper JavaScript Date.
|
||||
* HAFAS dates are "YYYYMMDD" and times are "HHMMSS".
|
||||
* The resulting Date represents the correct UTC instant for the Vienna local time.
|
||||
*
|
||||
* We try each plausible Vienna offset (CET +60 or CEST +120), compute the
|
||||
* candidate UTC timestamp via Date.UTC, and verify by checking that Vienna's
|
||||
* offset at that candidate instant matches the assumed offset. The first match
|
||||
* wins. This correctly handles DST transitions.
|
||||
*
|
||||
* Ambiguous times during the fall-back DST transition (02:xx on Oct 27, 2024):
|
||||
* HAFAS resolves ambiguous local times by using the post-transition (standard)
|
||||
* interpretation. Our loop tries CET (+60) before CEST (+120), so ambiguous
|
||||
* hours naturally resolve to CET — matching HAFAS convention. For example
|
||||
* "023000" on "20241027" → Oct 27 01:30:00 UTC (the second 02:30, not the
|
||||
* first).
|
||||
*/
|
||||
export function parseHafasTime(dateStr: string, timeStr: string): Date {
|
||||
const y = parseInt(dateStr.slice(0, 4), 10);
|
||||
const mo = parseInt(dateStr.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(dateStr.slice(6, 8), 10);
|
||||
const h = parseInt(timeStr.slice(0, 2), 10);
|
||||
const m = parseInt(timeStr.slice(2, 4), 10);
|
||||
const s = parseInt(timeStr.slice(4, 6), 10);
|
||||
|
||||
for (const offsetMin of [60, 120]) {
|
||||
const candidateTs = Date.UTC(y, mo, d, h, m, s, 0) - offsetMin * 60_000;
|
||||
const actualOffset = getTimezoneOffsetMinutes(new Date(candidateTs), "Europe/Vienna");
|
||||
if (actualOffset === offsetMin) {
|
||||
return new Date(candidateTs);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — should not happen for valid Vienna times.
|
||||
// Treat as CET (+1).
|
||||
return new Date(Date.UTC(y, mo, d, h, m, s, 0) - 60 * 60_000);
|
||||
}
|
||||
|
||||
// Helper: extract date/time components of an instant in a given IANA timezone
|
||||
// via Intl.DateTimeFormat. Returns { year, month (1-based), day, hour, minute, second }.
|
||||
function getDateTimeParts(instant: Date, tz: string) {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = fmt.formatToParts(instant);
|
||||
const get = (type: string) => parseInt(parts.find((p) => p.type === type)!.value, 10);
|
||||
|
||||
return {
|
||||
year: get("year"),
|
||||
month: get("month"), // 1-based
|
||||
day: get("day"),
|
||||
hour: get("hour"),
|
||||
minute: get("minute"),
|
||||
second: get("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HAFAS date and time strings from a JavaScript Date.
|
||||
*
|
||||
* Components are extracted in Europe/Vienna so the output is always a valid
|
||||
* Vienna-local HAFAS timestamp, regardless of the server's timezone.
|
||||
* This is the inverse of parseHafasTime: hafasDateTime(parseHafasTime(d, t))
|
||||
* will return { date: d, time: t } for every valid Vienna date/time pair.
|
||||
*/
|
||||
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`;
|
||||
return { date: d, time: t };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Re-export everything for clean imports
|
||||
export * from './types';
|
||||
export * from './countdown-utils';
|
||||
export * from './formatting';
|
||||
export * from './status-utils';
|
||||
export * from './hafas-time';
|
||||
@@ -0,0 +1,16 @@
|
||||
// Status utilities for TimeToLeave
|
||||
import type { ServerStatus } from "./types";
|
||||
|
||||
export class StatusUtils {
|
||||
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Core domain types
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: string;
|
||||
eventTime: Date;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: string;
|
||||
eventTime: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
name: string;
|
||||
extId: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface Journey {
|
||||
id: string;
|
||||
sD: Date; // scheduled departure
|
||||
rD: Date; // real departure
|
||||
sA: Date; // scheduled arrival
|
||||
rA: Date; // real arrival
|
||||
delay: number; // delay in minutes
|
||||
platform: string;
|
||||
changes: number;
|
||||
trains: string[];
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface BikeStep {
|
||||
name: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface BikeRoute {
|
||||
distance: number;
|
||||
duration: number;
|
||||
steps: BikeStep[];
|
||||
}
|
||||
|
||||
export interface CountdownInfo {
|
||||
label: string;
|
||||
color: string;
|
||||
urgent: boolean;
|
||||
}
|
||||
|
||||
export type LocState = "pending" | "granted" | "denied";
|
||||
export type CalStatus = null | "loading" | "ok" | "error";
|
||||
|
||||
// ── Reminder Settings ──────────────────────────────────
|
||||
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ServerStatus = boolean | null;
|
||||
|
||||
export interface GeocodeResult {
|
||||
lat: number;
|
||||
lng: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// WienerLinien types
|
||||
|
||||
export interface WienerLinienLine {
|
||||
name: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface WienerLinienStop {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface WienerLinienDeparture {
|
||||
stopId: string;
|
||||
line: WienerLinienLine;
|
||||
direction: string;
|
||||
departureTime: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface WienerLinienMonitorResponse {
|
||||
stops: {
|
||||
stopId: string;
|
||||
departures: WienerLinienDeparture[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface NearbyStop {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user