mobile app phase 1

This commit is contained in:
2026-05-10 21:19:39 +02:00
parent 442a00dbbe
commit d08afc1dcb
1679 changed files with 392410 additions and 3005 deletions
+17
View File
@@ -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"
}
}
+48
View File
@@ -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,
};
}
+41
View File
@@ -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`;
}
+115
View File
@@ -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 };
}
+6
View File
@@ -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';
+16
View File
@@ -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;
}
}
}
+110
View File
@@ -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;
}
+15
View File
@@ -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"]
}