67 Commits

Author SHA1 Message Date
fegger 51565882ca Update package-lock.json
CI / lint-typecheck-test (push) Has been cancelled
2026-05-19 16:27:41 +02:00
fegger 229e0dd557 Add interactive map support for bike and walk routes 2026-05-19 15:38:45 +02:00
fegger 7da5acbba3 Add tests and improve localStorage resilience
CI / lint-typecheck-test (push) Has been cancelled
2026-05-19 14:46:03 +02:00
fegger 72f9400756 Add CI pipeline, pre-commit hooks, and offline caching
CI / lint-typecheck-test (push) Has been cancelled
Configure GitHub Actions for linting, typechecking, and testing.
Add Husky and lint-staged for pre-commit checks. Implement API
response caching in AsyncStorage for offline support. Move core
tests to packages/core and add vitest configuration.
2026-05-19 14:00:38 +02:00
fegger 0493fcc939 Refactor AddEventModal state and improve useRouteQuery hook
Initialize AddEventModal state from editEvent props directly, removing
the useEffect that synchronized state. Add AbortController to
useRouteQuery to cancel in-flight requests when dependencies change or
component unmounts.

Add @testing-library/user-event and fix related tests to handle async
user events. Polyfill localStorage in Vitest setup for jsdom environment.
2026-05-19 13:12:33 +02:00
fegger 44aaa32296 Add edit icon button to event list item
Remove emoji icons from journey display and replace with FontAwesome icons
Add FontAwesome icon imports and styles for new icon usage
Update calendar service to use FontAwesome icons instead of emojis
Remove redundant emoji field from CalendarSourceInfo type
Adjust journey scoring weights to prioritize fewer changes
Remove emoji icons and simplify event metadata display
2026-05-19 00:06:53 +02:00
fegger bb286e1667 Refactor component styles to use CSS utility classes
Convert inline styles to reusable CSS classes for better maintainability
2026-05-18 22:32:09 +02:00
fegger 25676bf1e7 Simplify route hooks with shared query logic
Refactor AddEventModal state management
2026-05-18 22:13:53 +02:00
fegger 9c1f6ebf7e Fix React hook violations and optimize component behavior
Update form state management in AddEventModal and improve GoogleTab OAuth handling

Refactor EventListScreen to remove unused state calculation

Add missing dependencies in JourneyList useMemo hooks

Simplify events store initialization in EventsProvider
2026-05-18 21:56:11 +02:00
fegger b240d638ef Translate UI text to English
Translate mobile app UI text to English
2026-05-18 21:50:46 +02:00
fegger 2ac1777570 Update AppNavigator.tsx 2026-05-18 17:01:27 +02:00
fegger 7018443b18 update documentation 2026-05-18 15:01:53 +02:00
fegger 834025e560 Show leave time instead of countdown
Replace live countdown with formatted leave time in headers and list items
Remove ticking interval and calculateCountdown import from EventHeader
Update EventListScreen to derive leave countdown and labels and adjust badge,
dot color, status logic and fallbacks
Update tests to expect 'Losgehen in'
2026-05-18 13:14:05 +02:00
fegger b3edf2c47b Support multiple API base URLs with fallback
Support Multiple API Base URLs With Fallback

Make ApiClient accept multiple base URLs and try the next when
responses indicate unavailability (408, 429, 502, 503, 504 or any >=500)
or on network errors. Add fetchApi helper and selection logic, and use a
Tailscale dev server as a fallback in the mobile service. Also update
EventHeader to show a live leave-by countdown.
2026-05-18 12:43:04 +02:00
fegger 44fb492759 Use extId station lookup and clamp walk durations
Add ApiClient.findStationByExtId and toStation helper; use it in
useOriginStationWalk. Clamp OSRM route/step durations to a minimum
walking speed (1.25 m/s) and update tests to mock the new API call.
2026-05-18 12:15:13 +02:00
fegger ce1fa4972c Account for origin walk in departure time
Add useOriginStationWalk and integrate it into EventDetail/EventList to
resolve walking time from origin to station. Introduce calculateLeaveByTime
for notification scheduling and use exported Expo trigger types. Support
HAFAS 'crd' coordinates in client and destination hooks, update tests,
jest mappings, and Expo run scripts.
2026-05-18 11:56:49 +02:00
fegger 9d6efdd43b Configure Expo mobile project and update Android/iOS bundles
Initialize iOS and Android native projects for the Time to Leave app.
Rename Android package to com.floegger.timetoleave and add iOS
project files. Add FontAwesome icons, calendar types, and
dependencies.
2026-05-15 18:26:51 +02:00
fegger b3bba8cf38 Configure android build settings and dependencies 2026-05-14 19:10:12 +02:00
fegger e52f2497e3 Add GTFS enrichment to HAFAS response
Implements enrichment for HAFAS API responses by cross-referencing
section data against an indexed ÖBB GTFS feed.

This involves adding logic to fetch, parse, and index the GTFS data
from the specified URL. The enrichment function now uses GTFS time
and station data to populate `gtfsName` and `gtfsDirection` fields
for missing journey data in HAFAS responses.

Updates are also made to:
- Update `apps/web/src/lib/constants.ts` with the GTFS URL.
- Create `apps/web/src/lib/oebb-gtfs.ts` to handle GTFS fetching and indexing.
- Enhance `apps/web/src/app/api/hafas/route.ts` to utilize the new
  enrichment function.
  -Package updates include `fflate` and minor fixes to other packages.
2026-05-14 18:53:41 +02:00
fegger 04e793cbb8 Implement journey ranking and improve UI presentation
Add `rankJourneys` utility to score connections based on arrival fit,
directness, and duration. Update `JourneyList` to display the best option
first with a "Top" badge and allow expanding to see more. Fix HAFAS
parser to include train direction in journey details. Limit API result
count to 5 and add TypeScript declaration for PNG assets.
2026-05-14 17:05:56 +02:00
fegger 0501d66fdc Update mobile app icons 2026-05-14 16:41:43 +02:00
fegger c221ef934a Add nav logo image and update AppNavigator 2026-05-14 13:53:43 +02:00
fegger 498958a27c Add JSDoc documentation to all TypeScript files 2026-05-14 13:53:12 +02:00
fegger 09b5e7725d Refactor departure time calculation to account for walk duration
Introduce `trainWalkDurationSeconds` in `useDepartureTime` hooks for both
mobile and web apps to filter train journeys based on total arrival time
including walking.

Add default origin station constants in core package and use them in mobile
store instead of returning null when no origin is saved.

Normalize HAFAS coordinates in destination station hooks to handle
large integer values.

Update `cleanLocation` to preserve full addresses with commas and remove
the 10KB request body limit on ICS parsing to support larger calendar
files.

Make rate limiting configurable via environment variables to handle
higher API fan-out from calendar event pages.
2026-05-14 12:17:09 +02:00
fegger bf252a9e9b Refactor mobile UI and centralize HAFAS parsing
Introduce useColors hook to replace direct theme usage in mobile
screens. Extract EventHeader, JourneyList, BikeSection, and
NearbyStops components to reduce complexity in EventDetailScreen.

Move parseHafasJourneys to packages/core for shared usage between
web and mobile clients. Update web API route with stricter HAFAS
validation and consistent client instantiation.

Add comprehensive codebase function guide documenting data flow,
shared packages, and service integrations.
2026-05-13 19:13:27 +02:00
fegger 6c7d57106c Update logo assets and pin React 19.1.0 versions
Add new SVG icon and logo variations for web app. Revert React
and React DOM to 19.1.0 to align with type definitions and
overrides. Remove privacy policy URL from mobile config and
disable hierarchical lookup in Metro bundler.
Pin React to 19.1.0 and add new logo assets
2026-05-13 17:05:22 +02:00
fegger d0e61ebdb0 Update documentation with new features 2026-05-13 09:54:58 +02:00
fegger 9e461aab1a Switch mobile app to dark theme and fix calendar filtering
Switch mobile app to dark theme and fix calendar filtering

- Default to dark theme in mobile app, matching web app style
- Filter native calendar events by location to exclude empty ones
- Add batch edit panel for destinations on web calendar
- Add edit support to AddEventModal
- Update notification trigger input type export
2026-05-13 09:45:55 +02:00
fegger de9a8606ab Add Google Calendar integration and improve station selection
Implement full OAuth 2.0 flow for Google Calendar, including token
exchange, refresh, and status checks. Add UI for connecting, syncing,
and disconnecting Google accounts in the Calendar panel.

Additionally:
- Make mobile station selection async with error handling and alerts
- Add HAFAS LocMatch method to API client for finding nearest station
- Expose Google OAuth env vars in Next.js config
2026-05-13 09:02:58 +02:00
fegger 672d053ece Add core app features for event management and notifications
- Add new hook files for departure time, destination station, geocode, theme, walk route, and WienerLinien
- Add navigation types for centralized route definitions
- Update App.tsx to use ref for initialization logic
- Update notification service to use stable exports from expo-notifications
- Remove legacy notifications.ts and rename to expoNotifications.ts
- Add ScrollView to several screens for better layout
- Replace duplicated type definitions with imports from shared navigation types
- Add useTheme to relevant screens
- Remove notification handler duplication in App.tsx
2026-05-13 08:37:52 +02:00
fegger 316e5def72 Refactor mobile to fix dependency resolution and add polyfills
Update Expo and React dependencies to compatible versions. Create a
custom Metro config to resolve module conflicts in the workspace and
map Jest modules to local node_modules.

Add a SharedArrayBuffer polyfill for older runtimes and introduce an
adapter for expo-notifications to abstract direct imports.
2026-05-12 22:29:38 +02:00
fegger 3c6df95a86 Refactor monorepo structure and replace hardcoded colors
Add TypeScript project references, update ESLint configs to support
ESM modules, and introduce brand semantic color tokens across the web
app. Add comprehensive documentation for architecture, API reference,
development setup, and user guide.
2026-05-12 21:22:40 +02:00
fegger 98e74ee48d Update lint and typecheck scripts for all packages
Add linting to the mobile app and include core and api-client
packages in the root lint, typecheck, and test scripts. Ignore
node_modules in all subdirectories and clean up stale test results.
Update lint and typecheck scripts for all packages

Add ESLint configuration for mobile app and shared packages.
Rename mobile jest config to .cjs and enable ESM. Include
packages/core and packages/api-client in monorepo lint,
typecheck, and test scripts.
2026-05-12 17:47:47 +02:00
fegger 2eeae9a27b Add arrival buffer and transport options to notification settings
Update notification calculation to use arrival buffer
Add advanced settings toggle for arrival buffer and transport options
Fix reverse geocode call in SettingsScreen
2026-05-12 15:05:23 +02:00
fegger 35971596b3 Add API security guards, rate limiter, and manual test checklist
Implement strict CORS enforcement and per-IP rate limiting in the Next.js middleware. Add input validation helpers for
coordinates and request body size limits. Introduce SSRF protection for calendar URL fetching. Update mobile settings to
support new transport options and arrival buffers. Include a comprehensive manual testing checklist for integration
verification.
2026-05-12 14:42:11 +02:00
fegger 863996f06c Update BikeSection and EventCard with arrival buffer and walk options 2026-05-12 13:52:35 +02:00
fegger 1851d2ed47 Add TimeToLeave feature checklist and plan (50)
Add TimeToLeave feature checklist and plan
2026-05-12 13:17:17 +02:00
fegger eac4f6e216 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
2026-05-12 09:44:37 +02:00
fegger 2c65dc1d5a Update README.md 2026-05-11 23:02:58 +02:00
fegger b87acfd0e1 Update README to reflect logo and feature scope
Removes mention of bike route calculation from the main
description as this functionality is not implemented in
the current scope.
2026-05-11 23:01:06 +02:00
fegger 08794eae05 Update branding guidelines and assets
Adds a comprehensive `BRAND_GUIDELINES.md` file detailing brand assets, color palettes, and typography.

This commit also introduces multiple SVG assets for various logo usages (icon, horizontal, dark mode), updates the main
`layout.tsx` metadata, and adds the necessary component files (`LogoIcon.tsx`, `LogoHorizontal.tsx`, etc.) to support
these new assets.
2026-05-11 22:59:03 +02:00
fegger dc5b40ff6e Update .gitignore 2026-05-11 21:10:42 +02:00
fegger 4366f781d3 chore: remove agent config files from git tracking 2026-05-11 21:08:02 +02:00
fegger ea88e9d34a chore: remove IDE and agent_loop dirs from git tracking 2026-05-11 21:05:27 +02:00
fegger b541c809b2 Update .gitignore 2026-05-11 21:02:31 +02:00
fegger 014fe789f8 chore: bump to v1.0.0 and add CHANGELOG 2026-05-11 20:28:59 +02:00
fegger ef36d227e9 chore: remove accidentally tracked Next.js build artifacts from .next/ 2026-05-11 19:52:36 +02:00
fegger 6fb7941d56 Update README.md 2026-05-11 19:09:24 +02:00
fegger 55c77c7572 Update README.md 2026-05-11 19:00:02 +02:00
fegger fff5700132 Add README for TimeToLeave project
Adds comprehensive setup and structure documentation to the root README file. This outlines the monorepo structure,
prerequisites, and development workflow for new contributors.
2026-05-11 18:55:01 +02:00
fegger 5bcfafcbaf Add mobile services, web proxy, and Gemma4 agent loop
- Mobile: add push notification and calendar sync services with full test suite (calendar, eventStore, notifications,
  screens)
- Mobile: add EAS build config and Jest setup
- Web: add API proxy, update useDestinationStation/useJourneys hooks, add middleware tests
- Web: update next.config and rebuild
- Agent: add Gemma4-based agent loop (agent_base, ttl_agent, ts_agent)
- Docs: add privacy policy, post-MVP plan, and aider rules
2026-05-11 18:32:54 +02:00
fegger 20159262c1 Add getLeaveStatus utility and update project status
- Introduce `getLeaveStatus` function in `packages/core/src/status-utils.ts` to determine leave-by status based on
  journey data
- Mark Phase 1 mobile app tasks as complete in `CHECKLIST.md`
- Add mobile workspace configurations and npm scripts
2026-05-10 22:14:41 +02:00
fegger d08afc1dcb mobile app phase 1 2026-05-10 21:19:39 +02:00
fegger 442a00dbbe Add Wiener Linien integration with API routes and client 2026-05-10 19:20:19 +02:00
fegger 330cbb6b37 Refactor EventCard tests to verify hook arguments 2026-05-10 19:11:04 +02:00
fegger de9c16430b Support repeated stopIds and update WienerLinien API URL 2026-05-10 18:06:41 +02:00
fegger b2608a4a60 Fix ApiClient mock implementation and update constructor tests 2026-05-10 17:53:19 +02:00
fegger 7901368971 Add Wiener Linien API integration and departures monitoring 2026-05-10 17:19:24 +02:00
fegger 48e6f5c7fd Add leave reminder feature with browser notifications 2026-05-10 10:32:55 +02:00
fegger c0f34b9e2a Update CHECKLIST.md 2026-05-10 09:49:22 +02:00
fegger ce2a900545 Add correlation IDs and tests for monitoring
Generate short UUIDs in API route catch blocks to aid debugging.
Update existing API tests to assert the correlation ID. Add unit
tests for `useJourneys` and `useBikeRoute` hooks, and component
tests for `EventCard` and `CalendarView`.
2026-05-10 08:59:17 +02:00
fegger 7abfddd607 Add debounce, theme toggle, and calendar optimization
Implement 400ms debounce with AbortController in useGeocode and
useDestinationStation to prevent excessive API calls. Add dark mode
toggle via new useTheme hook, persisting preference to localStorage
and applying to document root.

Pre-group calendar events by date using a Map in CalendarView to
replace O(n) filter operations with O(1) lookups.
2026-05-10 03:39:30 +02:00
fegger 28c25c32ab Refactor API routes and hooks to use library clients
- Wire `api/geocode` and `api/bike-route` to use `GeocodingClient`
  and `BikeRoutingClient` instead of raw fetch calls
- Move `parseHafasJourneys` from `useJourneys` to `hafas-client`
- Remove dead code `src/lib/live-status-utils.ts`
- Update test setup to import `@testing-library/jest-dom/vitest`
2026-05-10 03:23:07 +02:00
fegger 77b8c6db98 Rewrite implementation checklist and fix runtime bugs
Update CHECKLIST.md and REWRITE_PLAN.md to reflect the current
post-rewrite status and remaining tasks.

- Add input validation to /api/hafas route to enforce request shape
  and cap results
- Fix SSR crash in useBikeRoute by using relative fetch URLs
- Wire CalendarPanel to fetch calendar events and merge them into the
  global events store
2026-05-10 03:09:38 +02:00
fegger c869d4958e Upgrade Next.js to v16 and refactor API client
Bump Next.js from v9 to v16.2.6 and add node-ical to
serverExternalPackages. Remove Geist font dependencies in favor
of system fonts. Fix state updates in geocode hooks to prevent
stale closures. Expose ApiClient methods as public and improve
error handling in fetchWithRetry.
2026-05-10 02:07:47 +02:00
fegger a7fcbd811e Refactor API clients and update project structure for TimeToLeave 2026-05-10 01:47:22 +02:00
fegger 7c345785a7 Add Docker support with multi-stage build and standalone output 2026-05-10 00:42:39 +02:00
322 changed files with 35908 additions and 3340 deletions
+19
View File
@@ -0,0 +1,19 @@
# EditorConfig for TimeToLeave
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 100
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
+34
View File
@@ -1,8 +1,21 @@
# Server port # Server port
PORT=3001 PORT=3001
# Public deployment URL used for redirects and generated links
DEPLOYMENT_URL=https://timetoleave.app
# ÖBB HAFAS API # ÖBB HAFAS API
HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe
HAFAS_TIMEOUT_MS=10000
HAFAS_VER=1.36
HAFAS_LANG=eng
HAFAS_AID=hf7mcf9bv3nv8g5f
HAFAS_CLIENT_ID=OEBB
HAFAS_CLIENT_VER=6020700
HAFAS_CLIENT_NAME=oebbApp
# Optional ÖBB GTFS enrichment
OEBB_GTFS_URL=https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_2026.zip
# Nominatim geocoding (OpenStreetMap) # Nominatim geocoding (OpenStreetMap)
NOMINATIM_URL=https://nominatim.openstreetmap.org NOMINATIM_URL=https://nominatim.openstreetmap.org
@@ -12,3 +25,24 @@ OSRM_URL=https://router.project-osrm.org
# Nominatim user-agent / referer (required by their ToS) # Nominatim user-agent / referer (required by their ToS)
NOMINATIM_USER_AGENT=OebbPlanner/1.0 NOMINATIM_USER_AGENT=OebbPlanner/1.0
# Wiener Linien Open Data API
WIENER_LINIEN_API_URL=https://api.wienerlinien.at/darwin-v2
# CORS Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://timetoleave.app
# API rate limiting
API_RATE_LIMIT_MAX_REQUESTS=120
API_RATE_LIMIT_WINDOW_MS=60000
# Google Calendar OAuth (required for web Google Calendar sync)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
# Version returned by /api/health
APP_VERSION=0.1.0
# Mobile app backend URL for physical device builds
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
+13
View File
@@ -0,0 +1,13 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-typecheck-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build web
run: npm run build
env:
SKIP_ENV_VALIDATION: "true"
+24
View File
@@ -2,6 +2,7 @@
# dependencies # dependencies
/node_modules /node_modules
**/node_modules
# testing # testing
/coverage /coverage
@@ -27,3 +28,26 @@ npm-debug.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.aider*
# editor / agent tooling
.claude/
.idea/
.zed/
AGENTS.md
CLAUDE.md
AIDER_RULES.md
.aider.chat.history.md
.aider.input.history
# agent loop
agent_loop/
# agent loop generated output
logs/
runs/
# next.js build output (apps)
apps/web/.next/
apps/mobile/log.txt
+1
View File
@@ -0,0 +1 @@
npx lint-staged
-10
View File
@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/oebb_planner.iml" filepath="$PROJECT_DIR$/.idea/oebb_planner.iml" />
</modules>
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/oebb-planner-app" vcs="Git" />
</component>
</project>
-114
View File
@@ -1,114 +0,0 @@
# Rewrite Agent Rules
These rules apply when implementing the Next.js rewrite on the `rewrite/next` branch.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement**
Rules:
- The ✅ column is yours; the ✔️ column belongs to the review agent.
- After completing each numbered item, mark its ✅ box by changing `[ ]` to `[x]`.
- If you complete an optional item (`[~]`), change it to `[x]`. If you skip it, leave it as `[~]`.
- Before starting any item in a new phase, read `CHECKLIST.md` and confirm that every **required** (`[ ]`/`[x]`) item in all preceding phases has `[x]` in both ✅ and ✔️. Items marked `[~]` in both columns do not need to be completed first.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Rewrite Context
- `REWRITE_PLAN.md` is the guiding plan for the migration from the current CRA + Express app to a Next.js App Router + TypeScript app.
- When working on the rewrite, follow the migration phases in `REWRITE_PLAN.md` unless the user explicitly asks for a different order.
- Treat each numbered migration item as a checkpoint: implement it, update its ✅ box in `CHECKLIST.md`, add or update tests, run the relevant verification, then continue.
- Prefer building the new Next.js structure in parallel until feature parity is proven. Do not delete `server/`, `oebb-planner-app/`, or `oebb-planner.jsx` before equivalent Next.js behavior is implemented, tested, and the user has clearly asked for cleanup.
- Preserve existing API contracts and user-visible behavior during migration unless the rewrite plan or user request explicitly changes them.
- Use `npm` consistently because the existing project uses `package-lock.json`.
## Work Step By Step
- Start by reading the relevant files and identifying the smallest safe next step.
- State the plan before making non-trivial changes.
- Implement one coherent change at a time.
- After each step, review the diff and check whether it still matches the intended behavior.
- Do not move on to the next step while the current step has unresolved compile errors, failing tests, or obvious regressions.
- Prefer small, targeted edits over broad rewrites.
- Preserve existing behavior unless the user explicitly asks to change it.
- When a task spans multiple rewrite phases, complete one vertical slice at a time where practical: type or library code, route or hook, UI integration, tests, then verification.
- Keep reusable logic in `src/lib`, side effects in hooks or route handlers, and shared contracts in `src/types`.
## Testing Requirements
- Add or update tests for every new feature, bug fix, and behavior change.
- Put tests near the code they cover and follow the existing test style.
- Cover the main success path, important edge cases, and failure behavior.
- Do not remove or weaken tests just to make the suite pass.
- If a change cannot reasonably be tested, explain why and add the closest practical verification.
- For the Next.js rewrite, prefer unit tests for `src/lib`, route tests for `src/app/api`, and component smoke or behavior tests for UI components.
- Mock external services in automated tests, including ÖBB HAFAS, Nominatim, OSRM, geolocation, time, and calendar downloads. Do not make tests depend on live network availability.
- Test TypeScript data shapes and boundary parsing where API responses are transformed into app types.
## Verification Before Moving On
- Run the narrowest relevant tests after each meaningful change.
- Run the broader project checks before finishing.
- For server changes, run:
```bash
cd server
npm test
```
- For React app changes, run:
```bash
cd oebb-planner-app
CI=true npm test -- --watchAll=false
npm run build
```
- For the Next.js rewrite, once the root Next.js project exists, run the relevant root checks instead:
```bash
npm test
npm run build
```
- If available, also run type-checking and linting scripts before finishing:
```bash
npm run typecheck
npm run lint
```
- If a change touches both server and app behavior, run both sets of checks.
- If a command fails, stop, inspect the failure, fix the cause, and rerun the command.
- Do not claim the work is complete until the relevant checks pass, or until the remaining blocker is clearly reported.
## Quality Bar
- Make sure additions do not introduce compile errors, lint errors, runtime crashes, or broken imports.
- Check that public APIs, endpoint contracts, props, and data shapes remain compatible with existing callers.
- Keep error handling explicit and user-facing failures understandable.
- Avoid hidden global state, timing assumptions, and network-dependent tests unless the project already uses that pattern.
- Keep dependencies unchanged unless they are necessary for the task and justified.
- Do not commit generated artifacts, caches, logs, or local environment files.
- Keep TypeScript strictness intact once introduced. Do not use `any` as a shortcut around unclear domain types.
- Keep server-only code out of client components. Route handlers and `src/lib` clients that use secrets, privileged headers, or upstream service details must not be imported into browser-only code.
- Respect Nominatim usage requirements when implementing geocoding: configurable base URL, clear user agent, rate-limit-aware caching, and no direct browser calls to the public service.
- Keep OSRM and HAFAS clients behind API routes or server-side utilities so failures can be normalized and tested.
- For UI work, preserve accessibility basics: semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
## Completion Checklist
Before finishing a step, confirm:
- The requested behavior is implemented.
- The ✅ box for the corresponding item in `CHECKLIST.md` is checked.
- The change matches the relevant phase or numbered item in `REWRITE_PLAN.md`, when applicable.
- Tests were added or updated where appropriate.
- Relevant tests and build checks pass.
- The change is scoped to the request.
- No unrelated user changes were overwritten.
- Old implementation files were not removed unless parity is tested and cleanup was requested.
- Any limitations or skipped checks are reported clearly.
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+84
View File
@@ -0,0 +1,84 @@
# TimeToLeave Brand Guidelines
## Logo
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
- **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 Gradient
| Name | Hex | Usage |
|------|-----|-------|
| Violet | `#8B5CF6` | Gradient start |
| Magenta | `#B23CFF` | Gradient mid |
| Pink | `#D946EF` | Gradient end |
| Hot Pink | `#FF2D8D` | Accents, arrows, "To" in wordmark |
### Text
| Name | Hex | Usage |
|------|-----|-------|
| 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 |
|--------|-------|---------|
| Red | `#FF3B30` | Leave now / Late |
| Orange | `#FF9500` | Getting close |
| Yellow | `#FFCC00` | On track |
| Green | `#34C759` | Plenty of time |
| Blue | `#5AC8FA` | Confirmed / Done |
## Typography
- **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
### Icon Only
Use `LogoIcon` component for favicons, app icons, loading states.
### Horizontal Logo
Use `LogoHorizontal` component for headers, navigation, about pages.
### Full Logo (SVG)
Use `timetoleave_dark_logo.svg` for downloads, print, marketing materials.
## Usage
```tsx
import { LogoIcon, LogoHorizontal } from "@/app/ui/logos";
// Icon only (48px)
<LogoIcon size={48} />
// Horizontal header logo
<LogoHorizontal height={32} />
// Custom sizing
<LogoIcon size={128} className="drop-shadow-lg" />
```
## File Locations
| File | Purpose |
|------|---------|
| `apps/web/src/app/ui/LogoIcon.tsx` | React icon component |
| `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/web/src/app/timetoleave_dark_logo.svg` | Master SVG with full wordmark |
+92
View File
@@ -0,0 +1,92 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
### Mobile Application
- Default to dark theme across the mobile app
- Filter native calendar events by location to exclude empty ones
- Async station selection with error handling and alerts
- Improved notification settings UI
- Native calendar source selection with CalDAV/DAVx, Apple, Google, Exchange, subscribed, local, CardDAV, ActiveSync, and other source labels
- Event detail sections split into reusable header, journey, bike, and nearby-stop components
### Calendar Integration
- **Google Calendar sync** via full OAuth 2.0 flow (token exchange, refresh, and status checks)
- UI for connecting, syncing, and disconnecting Google accounts in the Calendar panel
- Batch edit panel for managing event destinations on the web calendar
- Edit support in AddEventModal for modifying existing events
### Public Transport
- HAFAS LocMatch method in API client for finding nearest station to current location
- Improved station selection with async search and error handling
- WienerLinien departures hook improvements
- HAFAS response enrichment through the ÖBB GTFS fallback when available
- Arrive-by journey search with fallback search window
### API Client
- Added `findStationByExtId` for resolving a saved station ID
- Added `findNearestStationByCoords` for geolocation-based station lookup
- Added base URL failover for backend availability issues
- Enhanced HAFAS request handling
### Documentation
- Updated README, development, architecture, API, user, privacy, and testing documentation to match the current route tree and app behavior
---
## [1.0.0] — 2025-01-27
### 🎉 Initial MVP Release
First stable release of TimeToLeave: a smart departure planner that integrates
your calendar with real-time public transport data to tell you exactly when to leave.
### Web Application
- Next.js 16 web dashboard with Tailwind CSS 4
- Calendar sync via `.ics` file import or URL
- Station search and journey planning for upcoming events
- Real-time departures with dynamic leave-status countdown
- Browser-based leave reminders with push notifications
- Dark/light theme toggle
- Debounced search and optimized calendar loading
- Docker support with multi-stage build and standalone output
### Mobile Application
- React Native 0.81 / Expo 54 mobile client
- Five-screen navigation: Event List, Add Event, Event Detail, Origin Setup, Settings
- Native calendar import via `expo-calendar`
- Geolocation-based origin detection via `expo-location`
- Local push notifications via `expo-notifications`
- Persistent settings and state via AsyncStorage
### Public Transport Integration
- HAFAS protocol support for Austrian railway (ÖBB) real-time departures
- WienerLinien integration for Vienna U-Bahn, tram, and bus departures
- Accurate timezone-aware HAFAS time parsing with DST transition handling for `Europe/Vienna`
- Support for repeated stop IDs and multiple connections
### Routing
- Bike route calculation and final walking route calculation via OSRM
- Geocoding API integration for station lookups
- Fallback and caching logic for API failures
### Shared Infrastructure
- Monorepo structure with npm workspaces
- `@timetoleave/core` — shared types, countdown utilities, and leave-status logic
- `@timetoleave/api-client` — typed client for HAFAS, calendar, geocoding, and bike routing
- Correlation IDs for request tracing and monitoring
- Comprehensive test coverage across web (Vitest) and mobile (Jest)
- Strict TypeScript type-checking across all workspaces
- ESLint linting across the codebase
+41 -17
View File
@@ -1,23 +1,47 @@
## Phase 9 — Fix Broken Tests (~1 hour) # TimeToLeave — Implementation Checklist
The API route tests were written against an earlier interface and will fail as-is. ## Phase 1 — Workspace + Shared Packages (Steps 1-9)
| # | Item | ✅ | ✔️ | | # | Step | ✅ | ✔️ |
|---|---|----|-| |---|---|----|-|
| 53 | `geocode.test.ts`: change `?q=``?name=` to match the actual route parameter | [x] | [x] | | 1 | Create safety baseline (typecheck, lint, test, build) | [x] | [x] |
| 54 | `geocode.test.ts`: fix expected error strings (`"Failed to geocode location"``"Internal server error"` / `"No results found"`) | [x] | [x] | | 2 | Add workspace support to root `package.json` | [x] | [x] |
| 55 | `bike-route.test.ts`: change `?start=` / `?end=``?fromLat=&fromLng=&toLat=&toLng=` to match actual route | [x] | [x] | | 3 | Move web app into `apps/web/` | [x] | [x] |
| 56 | `bike-route.test.ts`: fix expected error strings (`"Missing 'start' or 'end' parameter"``"Missing required parameters ..."` and `"Failed to fetch bike route"``"Internal server error"`) | [x] | [x] | | 4 | Create `packages/core/` with shared types + utilities | [x] | [x] |
| 57 | `calendar-utils.test.ts` (`extractEvents`): replace hardcoded past dates (2020-01-01, 2023-01-01) with `vi.setSystemTime` + dates relative to the frozen clock so filters behave as expected | [x] | [x] | | 5 | Update web imports to use `@timetoleave/core` | [x] | [x] |
| 6 | Create `packages/api-client/` with typed API wrapper | [x] | [x] |
| 7 | Refactor web hooks to use `api-client` | [x] | [x] |
| 8 | Run web verification again (typecheck, lint, test, build) | [x] | [x] |
| 9 | Phase 1 summary verification (all packages compile) | [x] | [x] |
## Phase 10Fix Architecture & Critical Bugs (~2 hours) ## Phase 2Expo Mobile MVP (Steps 10-18)
| # | Item | ✅ | ✔️ | | # | Step | ✅ | ✔️ |
|---|---|----|-| |---|---|----|-|
| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [x] | [x] | | 10 | Scaffold Expo mobile app with TypeScript | [x] | [x] |
| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [x] | [x] | | 11 | Configure mobile API base URL (`.env` + singleton) | [x] | [x] |
| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [x] | [x] | | 12 | Add mobile app shell (navigation + 5 screens) | [x] | [x] |
| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [x] | [x] | | 13 | Add mobile event store (AsyncStorage) | [x] | [x] |
| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [x] | [x] | | 14 | Build Event List screen | [x] | [x] |
| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [x] | [x] | | 15 | Build Add Event screen with validation | [x] | [x] |
| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [x] | [x] | | 16 | Build Origin Setup in Settings | [x] | [x] |
| 17 | Build Event Detail screen (trains + bike + errors) | [x] | [x] |
| 18 | Phase 2 summary verification | [x] | [x] |
## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24)
| # | Step | ✅ | ✔️ |
|---|---|----|-|
| 19 | Add local notifications (expo-notifications) | [x] | [x] |
| 20 | Add native calendar import with calendar selection | [x] | [x] |
| 21 | Add mobile tests (core + API + store + screens) | [x] | [x] |
| 22 | Prepare deployment (web backend + EAS mobile) | [x] | [x] |
| 23 | Release MVP (verify acceptance criteria) | [x] | [x] |
| 24 | Plan post-MVP improvements | [x] | [x] |
---
**Legend:**
- ✅ = Done (code written)
- ✔️ = Verified (tests/builds pass)
- `[~]` = Optional or deferred (never blocks phase advancement)
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+57
View File
@@ -0,0 +1,57 @@
# TimeToLeave - Features Implementation Checklist
This checklist tracks the route-planning and settings features currently implemented across the web and shared packages.
## Settings Infrastructure
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 1 | Extend `ReminderSettings` with arrival buffer, walking visibility, and bike visibility | [x] | [x] |
| 2 | Persist reminder settings in web `localStorage` and mobile `AsyncStorage` | [x] | [x] |
| 3 | Add settings UI for reminder buffer, arrival buffer, notifications, walking, and bike options | [x] | [x] |
## Routing Infrastructure
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 4 | Add OSRM walking client and `/api/walk-route` endpoint | [x] | [x] |
| 5 | Add OSRM bike client and `/api/bike-route` endpoint | [x] | [x] |
| 6 | Add `getWalkRoute()` and `getBikeRoute()` to `@timetoleave/api-client` | [x] | [x] |
| 7 | Add web/mobile hooks for walking and bike routes | [x] | [x] |
## Departure Calculation
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 8 | Calculate leave-by time from selected transport mode | [x] | [x] |
| 9 | Account for final walking time before choosing train journeys | [x] | [x] |
| 10 | Support HAFAS arrive-by journey search with fallback window | [x] | [x] |
| 11 | Update countdown logic to use computed departure time | [x] | [x] |
## Calendar and Event Management
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 12 | Import web calendars from URL and local ICS files | [x] | [x] |
| 13 | Add Google Calendar OAuth sync on web | [x] | [x] |
| 14 | Add batch destination review/editing for imported web events | [x] | [x] |
| 15 | Add mobile native calendar sync with calendar selection | [x] | [x] |
| 16 | Add event editing on web and mobile | [x] | [x] |
## Transit Integrations
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 17 | Add HAFAS station search and nearest-station lookup | [x] | [x] |
| 18 | Add HAFAS journey parsing with real-time delay/cancellation support | [x] | [x] |
| 19 | Add optional ÖBB GTFS train metadata enrichment | [x] | [x] |
| 20 | Add Wiener Linien nearby stops and monitor departures | [x] | [x] |
## Verification
| # | Step | Done | Verified |
| --- | --- | --- | --- |
| 21 | Web unit and route tests | [x] | [x] |
| 22 | Mobile store, calendar, notification, and screen tests | [x] | [x] |
| 23 | Root lint/typecheck/test scripts documented | [x] | [x] |
| 24 | Manual integration checklist updated | [x] | [x] |
+145
View File
@@ -0,0 +1,145 @@
# TimeToLeave - Manual Testing Checklist
Use this checklist for browser, mobile, and integration testing before release.
## Prerequisites
- [ ] `npm install` has been run.
- [ ] Required environment variables are configured.
- [ ] Web app is running locally or deployed.
- [ ] Mobile app has a reachable `EXPO_PUBLIC_API_BASE_URL` when tested on a device.
- [ ] Network access is available for HAFAS, Nominatim, OSRM, Wiener Linien, and calendar providers.
## Web Dashboard
- [ ] Open `/`.
- [ ] Verify the departure desk loads without console errors.
- [ ] Add or import at least two future events.
- [ ] Verify the dashboard shows the next upcoming event.
- [ ] Verify edit and remove actions work from the event card.
- [ ] Verify event data persists after browser refresh.
## Web Calendar Import
- [ ] Open `/calendar`.
- [ ] Import a valid allowed ICS URL.
- [ ] Upload a local `.ics` file.
- [ ] Verify imported events with locations merge into the local event store.
- [ ] Verify duplicate imports do not create unusable duplicate records.
- [ ] Use batch destination editing and confirm edited destinations are retained.
## Google Calendar Web Sync
- [ ] Confirm `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, and `DEPLOYMENT_URL` are configured.
- [ ] Open the Google tab on `/calendar`.
- [ ] Connect Google Calendar through OAuth.
- [ ] Verify sync returns upcoming events with locations.
- [ ] Disconnect Google Calendar.
- [ ] Verify status returns to disconnected.
## Settings and Reminders
- [ ] Enable browser notifications when prompted.
- [ ] Change reminder buffer and arrival buffer.
- [ ] Toggle walking option off and on.
- [ ] Toggle bike option off and on.
- [ ] Refresh the page and verify settings persist.
- [ ] Verify disabling bike hides or disables bike mode.
- [ ] Verify disabling walking removes the final-walk adjustment from train mode.
## Train Mode
- [ ] Use an event with a real destination and origin station.
- [ ] Verify destination geocoding completes.
- [ ] Verify destination station lookup completes.
- [ ] Verify `/api/hafas` is called with `TripSearch`.
- [ ] Verify journey rows show departure, arrival, platform, train labels, delay, changes, and cancellations when present.
- [ ] Verify leave-by time is based on a journey that arrives before the event minus arrival buffer and final walk.
- [ ] Increase arrival buffer and verify leave-by can move earlier.
## Bike and Walking Routes
- [ ] Switch to bike mode.
- [ ] Verify `/api/bike-route` is called with four coordinate parameters.
- [ ] Verify bike duration, distance, and steps are displayed.
- [ ] Switch back to train mode with walking enabled.
- [ ] Verify `/api/walk-route` is called for station-to-destination walking.
- [ ] Verify walking duration and distance appear in the train section.
- [ ] Test route error handling with invalid or very distant coordinates.
## Wiener Linien
- [ ] Use a destination near Vienna public transport.
- [ ] Verify `/api/wienerlinien/stops` returns nearby stops.
- [ ] Verify `/api/wienerlinien/monitor` returns live departures for selected stops.
- [ ] Verify loading, empty, and error states are readable.
## API Guards
- [ ] Verify remote calendar URLs from unsupported hosts are rejected.
- [ ] Verify private or localhost calendar URLs are rejected.
- [ ] Verify overly large HAFAS POST bodies are rejected.
- [ ] Verify invalid coordinates return client errors.
- [ ] Verify CORS allows only configured origins.
- [ ] Verify rate limiting returns `429` after the configured threshold.
## Mobile Event Flow
- [ ] Start the Expo app.
- [ ] Add a manual event.
- [ ] Edit the event.
- [ ] Delete the event.
- [ ] Restart the app and verify stored events persist.
- [ ] Open event detail and verify train, bike, walking, and nearby-stop sections load when data is available.
## Mobile Calendar Import
- [ ] Import an ICS URL.
- [ ] Grant calendar permission.
- [ ] Verify native calendars are listed.
- [ ] Select and deselect individual calendars.
- [ ] Use select all and deselect all.
- [ ] Sync native calendars for the next 30 days.
- [ ] Verify events without locations are excluded.
- [ ] Verify CalDAV/DAVx, Apple, Google, Exchange, subscribed, local, and other source labels render correctly when available on the device.
## Mobile Settings and Notifications
- [ ] Search for an origin station.
- [ ] Use current location to find nearest origin station.
- [ ] Change reminder buffer and arrival buffer.
- [ ] Toggle walking and bike options.
- [ ] Toggle notifications.
- [ ] Verify notification settings persist after app restart.
- [ ] Verify scheduled notifications are recreated when settings change.
- [ ] Toggle dark/light theme and verify it persists.
## Offline and Failure States
- [ ] Disable network and open web event detail data.
- [ ] Verify geocoding, HAFAS, route, and Wiener Linien errors are visible and non-blocking.
- [ ] Re-enable network and verify retry/refresh paths work.
- [ ] Test mobile with the backend URL unavailable and verify errors are understandable.
## Accessibility and Layout
- [ ] Navigate web controls with keyboard only.
- [ ] Verify modal focus and close behavior.
- [ ] Verify buttons and interactive controls have accessible labels or readable text.
- [ ] Test narrow mobile browser width, tablet width, and desktop width.
- [ ] Verify mobile screens do not clip primary controls.
## Sign-Off
- [ ] Web smoke test passed.
- [ ] Mobile smoke test passed.
- [ ] Calendar import tested.
- [ ] Live transit integration tested.
- [ ] Notifications tested.
- [ ] No critical bugs remain.
Tested by:
Date:
Build/version:
+87
View File
@@ -0,0 +1,87 @@
# TimeToLeave - Post-MVP Improvements Plan
> **Last updated:** 2026-05-19
> This plan reflects the current state of the codebase after the initial MVP and mobile rewrite.
## Already Implemented (No Longer TODO)
The following items from earlier versions of this plan have been completed and are in production:
- **Native Calendar Import** — `expo-calendar` integration with device calendar sync, permission requests, and multiple calendar source selection.
- **Push / Local Notifications** — `expo-notifications` with local notification scheduling. Server-side push (FCM/APNs) for live journey updates remains deferred.
- **Dark / Light Theming** — System theme following and manual dark/light toggle on both web and mobile.
- **Auto-Refresh** — `useFocusEffect`-based refresh on mobile when returning to foreground.
---
## High Priority
### 1. Offline-First Architecture
- Cache journey and route data in `AsyncStorage` / `localStorage` for offline viewing.
- Background sync when connection is restored.
- Add explicit "offline mode" UI indicators.
- Conflict resolution for concurrent event edits.
### 2. Real Map Integration
- Integrate `expo-maps` / `@vis.gl/react-google-maps` for visual route display.
- Show origin, destination, and train stations on a map.
- Display bike route with turn-by-turn directions.
- Alternative route suggestions (e.g., faster vs. fewer changes).
### 3. Multiple Origins Support
- Allow different origins per event (Home, Work, Custom presets).
- Quick origin switching in event detail and settings.
- Store origin presets in persistent settings.
---
## Medium Priority
### 4. Server-Side Push Notifications
- Implement FCM for Android and APNs for iOS for real-time journey disruption alerts.
- Real-time delay/cancellation push notifications.
- Fallback to local notifications when the server is unreachable.
### 5. Accessibility
- TalkBack / VoiceOver screen reader support on mobile.
- Dynamic type scaling.
- High contrast mode.
- WCAG 2.1 AA compliance audit on web.
### 6. Analytics & Crash Reporting
- Integrate Sentry for error tracking on web and mobile.
- Opt-in usage analytics.
- Performance monitoring (Web Vitals, React Native startup time).
- In-app user feedback collection.
---
## Lower Priority
### 7. Advanced Features
- Shared events with friends / family (collaborative departure planning).
- Recurring event templates.
- Journey history and statistics dashboard.
- Export / import event data (ICS, JSON).
---
## Technical Debt & Quality
### 8. Code Quality
- Extract shared hooks to `packages/hooks` (deduplicate web and mobile hook implementations).
- Increase unit test coverage across all packages.
- Add Playwright E2E tests for web critical flows.
- Add Detox E2E tests for mobile critical flows.
- Bundle size analysis and reduction.
### 9. Developer Experience
- Consolidate ESLint to Flat Config everywhere.
- Add pre-commit hooks (`husky` + `lint-staged`).
- Add GitHub Actions CI pipeline.
- Architecture Decision Records (ADRs) in `docs/adr/`.
### 10. Documentation
- Keep `POST_MVP_PLAN.md` and `CHECKLIST.md` in sync with reality.
- Contributing guidelines (`CONTRIBUTING.md`).
- API versioning policy once the backend grows.
+45
View File
@@ -0,0 +1,45 @@
# Privacy Policy
TimeToLeave is designed to keep user data local where possible. The app does not include third-party analytics or advertising trackers.
## Data Stored Locally
- Web events and reminder settings are stored in browser `localStorage`.
- Mobile events, origin station, notification settings, theme, and selected native calendars are stored in `AsyncStorage`.
- Mobile notifications are scheduled locally through Expo notifications.
## Data Sent to External Services
Some features require network calls to calculate routes or import calendars:
| Data | Sent to | Purpose |
| --- | --- | --- |
| Destination text or address | Nominatim | Convert a place into coordinates. |
| Coordinates | OSRM | Calculate bike and walking routes. |
| Station IDs, dates, and times | ÖBB HAFAS | Search stations and live public-transport journeys. |
| Coordinates or stop IDs | Wiener Linien | Find nearby stops and live departures. |
| Calendar URL | TimeToLeave backend, then the calendar host | Fetch and parse remote ICS feeds. |
| Google Calendar authorization code and tokens | Google and the TimeToLeave backend | Connect and sync Google Calendar on web. |
| Device calendar event fields | Local mobile app process | Import native calendar events with locations. |
Remote ICS imports are restricted by server-side URL validation. Private and reserved hosts are blocked.
## Google Calendar
Google Calendar sync is optional. When connected on the web app, OAuth tokens are stored in HTTP-only cookies and used only to fetch calendar events. Disconnecting Google Calendar deletes the token cookie.
## Location
Location access is optional and used to find nearby stations or calculate routes. Coordinates may be sent to route, geocoding, or transit APIs only when the corresponding feature is used.
## Calendar Data
Only events with locations are useful to TimeToLeave. Imported events are normalized to title, destination, event time, source, and ID. The app stores those normalized events locally.
## Data Retention
Local data remains until the user clears app/browser storage, deletes events, disconnects Google Calendar, or uninstalls the app. Server-side proxy routes are intended for request handling and do not provide application-level persistent event storage.
## Changes
This policy may be updated as the app changes. Updates are made in this repository.
+120 -13
View File
@@ -1,21 +1,128 @@
# TimeToLeave - Next.js Rewrite # TimeToLeave
This is a rewrite of the TimeToLeave application using Next.js App Router with TypeScript. TimeToLeave is a departure planner for calendar-driven travel. It imports events with locations, resolves the closest public-transport station, checks live ÖBB HAFAS and Wiener Linien data, and shows when to leave by train or bike.
![TimeToLeave Logo](apps/web/public/timetoleave_logo.png)
![Platform](https://img.shields.io/badge/platform-Web_%26_Mobile-blue) ![Next.js](https://img.shields.io/badge/Next.js-16.2-green) ![React Native](https://img.shields.io/badge/React%20Native-0.81-blue) ![Expo](https://img.shields.io/badge/Expo-54-black) ![TypeScript](https://img.shields.io/badge/TypeScript-5-blue)
## What It Does
1. Imports events from ICS URLs, ICS files, Google Calendar on web, or native device calendars on mobile.
2. Stores events locally in browser `localStorage` or mobile `AsyncStorage`.
3. Uses browser or device geolocation, a saved station, or the default Mödling origin.
4. Geocodes event destinations and resolves nearby stations through HAFAS `LocMatch`.
5. Searches ÖBB HAFAS journeys, enriches train data with the ÖBB GTFS fallback when available, and shows real-time delays and cancellations.
6. Calculates bike and final walking routes through OSRM.
7. Shows a live leave-by countdown and optional browser/mobile notifications.
## Repository Layout
| Path | Purpose |
| --- | --- |
| `apps/web/` | Next.js 16 App Router web UI plus backend proxy routes for HAFAS, geocoding, routing, calendar parsing, Google Calendar, and Wiener Linien. |
| `apps/mobile/` | Expo 54 / React Native 0.81 mobile app with native calendar, location, notification, and local storage integrations. |
| `packages/core/` | Shared types, defaults, HAFAS time parsing, journey parsing/scoring, countdown, formatting, and status utilities. |
| `packages/api-client/` | Shared client for calling the web app's `/api/*` backend routes from web hooks and the mobile app. |
| `docs/` | Architecture, development, API, user, and codebase reference documentation. |
## Prerequisites
- Node.js 20 or newer
- npm 9 or newer
- For mobile native builds: Expo/EAS prerequisites plus Android Studio or Xcode as needed
## Setup
```bash
npm install
cp .env.example .env
```
The web app reads environment variables from the workspace process. For deployment, configure the same values in the hosting environment.
Important variables:
| Variable | Purpose |
| --- | --- |
| `HAFAS_URL` | ÖBB HAFAS endpoint. Defaults to `https://fahrplan.oebb.at/bin/mgate.exe`. |
| `NOMINATIM_URL` and `NOMINATIM_USER_AGENT` | Geocoding endpoint and required user agent. |
| `OSRM_URL` | Routing endpoint used for bike and foot profiles. |
| `WIENER_LINIEN_API_URL` | Wiener Linien live data base URL. |
| `OEBB_GTFS_URL` | Optional ÖBB GTFS ZIP used to enrich HAFAS train metadata. |
| `CORS_ALLOWED_ORIGINS` | Comma-separated origins allowed to call `/api/*`. |
| `DEPLOYMENT_URL` | Public base URL used by Google OAuth redirects. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | Required for Google Calendar sync on web. |
| `EXPO_PUBLIC_API_BASE_URL` | Mobile backend URL. Set this for device builds so the app can reach the deployed web backend. |
## Development ## Development
```bash | Command | Description |
npm run dev | --- | --- |
``` | `npm run dev` | Start the Next.js web app on `http://localhost:3000`. |
| `npm run dev:mobile` | Start the Expo development server. |
| `npm run build` | Build the web app. |
| `npm run start` | Start the built web app. |
| `npm run test` | Run web Vitest and mobile Jest suites. |
| `npm run lint` | Run ESLint across web, mobile, core, and api-client workspaces. |
| `npm run typecheck` | Run TypeScript checks across all workspaces. |
## Building ## Web App
```bash Current user-facing routes:
npm run build
```
## Testing | Route | Description |
| --- | --- |
| `/` | Departure desk. Shows the next upcoming event, leave-by status, transport mode selector, train journeys, bike route, final walk, and nearby Wiener Linien departures. |
| `/calendar` | Calendar import and management view with URL, file, and Google Calendar tabs plus batch destination editing. |
```bash The add/edit event UI is a modal component, not a standalone page route.
npm test
``` ## Backend Proxy Routes
All backend routes live under `apps/web/src/app/api/` and are protected by strict CORS plus per-IP rate limiting in `apps/web/src/proxy.ts`.
| Endpoint | Methods | Purpose |
| --- | --- | --- |
| `/api/health` | `GET` | Returns `{ ok, ts, version }`. |
| `/api/hafas` | `GET`, `POST` | Convenience journey search or validated HAFAS relay for `TripSearch` and `LocMatch`. |
| `/api/geocode` | `GET` | Forward geocoding through Nominatim. |
| `/api/bike-route` | `GET` | OSRM bicycle route between two coordinates. |
| `/api/walk-route` | `GET` | OSRM foot route between two coordinates. |
| `/api/calendar` | `GET` | Fetch and parse an allowed remote ICS URL. |
| `/api/calendar/parse` | `POST` | Parse uploaded/raw ICS text. |
| `/api/calendar/google` | `GET` | Fetch Google Calendar events using OAuth cookies. |
| `/api/auth/google` | `GET` | Start Google OAuth. |
| `/api/auth/google/callback` | `GET` | Complete Google OAuth and store token cookie. |
| `/api/auth/google/status` | `GET` | Report Google configuration and connection state. |
| `/api/auth/google/disconnect` | `POST` | Delete the Google token cookie. |
| `/api/wienerlinien/stops` | `GET` | Find nearby Wiener Linien stops. |
| `/api/wienerlinien/monitor` | `GET` | Fetch and flatten live stop departures. |
## Mobile App
The mobile app includes event list, add/edit event, event detail, calendar import, and settings screens. It supports:
- Native calendar sync for the next 30 days.
- Calendar-source selection, including CalDAV/DAVx, Apple, Google, Exchange, subscribed, and local calendars when exposed by the device.
- Saved origin station with current-location lookup.
- Train, bike, walking, and Wiener Linien live sections on event detail.
- Local notifications scheduled from stored event/settings data.
- Dark/light theme toggle.
## Documentation
Start with [docs/README.md](docs/README.md), then use:
- [Architecture](docs/ARCHITECTURE.md)
- [Development Guide](docs/DEVELOPMENT.md)
- [Core & API Client Reference](docs/API_REFERENCE.md)
- [User Guide](docs/USER_GUIDE.md)
- [Codebase Function Guide](docs/CODEBASE_FUNCTION_GUIDE.md)
## Important Implementation Notes
- HAFAS date/time values are Vienna-local strings. Use `parseHafasTime()` and `hafasDateTime()` from `@timetoleave/core`; avoid ad hoc `Date` parsing for HAFAS payloads.
- Remote calendar URLs are restricted to known calendar providers and private/reserved hosts are blocked.
- Mobile devices must use a reachable `EXPO_PUBLIC_API_BASE_URL`; same-origin empty base URLs only work in the web app.
- This repo uses Next.js 16. Before changing Next.js routing, middleware/proxy, or framework conventions, read the relevant guide in `node_modules/next/dist/docs/`.
+17 -14
View File
@@ -1,32 +1,32 @@
# Review Agent Rules # Review Agent Rules
These rules apply when reviewing completed implementation steps on the `rewrite/next` branch. These rules apply when reviewing completed implementation steps on the `main` branch.
## Checklist Tracking ## Checklist Tracking
`CHECKLIST.md` uses three checkbox states: `FEATURES_CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase - `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done - `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement** - `[~]` — optional or deferred; **never blocks phase advancement**
Rules: Rules:
- The ✅ column belongs to the rewrite agent; the ✔️ column is yours. - The ✅ column belongs to the implementation agent; the ✔️ column is yours.
- Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items. - Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items.
- If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items. - If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items.
- After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`. - After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`.
- Before reviewing any item in a new phase, read `CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement. - Before reviewing any item in a new phase, read `FEATURES_CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding. - If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Review Scope ## Review Scope
- Review one phase at a time. Within a phase, review items in the order they appear in `CHECKLIST.md`. - Review one phase at a time. Within a phase, review items in the order they appear in `FEATURES_CHECKLIST.md`.
- For each item, cross-reference the implementation against `REWRITE_PLAN.md` and the quality criteria below. - For each item, cross-reference the implementation against `FEATURES_PLAN.md` and the quality criteria below.
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline. - Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline.
## What to Check ## What to Check
**Correctness** **Correctness**
- The behavior matches the intent described in `REWRITE_PLAN.md` and the checklist item. - The behavior matches the intent described in `FEATURES_PLAN.md` and the checklist item.
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers. - API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
- No regressions are introduced in previously working behavior. - No regressions are introduced in previously working behavior.
@@ -43,10 +43,11 @@ Rules:
- Error handling is explicit and user-facing failures are understandable. - Error handling is explicit and user-facing failures are understandable.
- No generated artifacts, caches, logs, or local environment files are committed. - No generated artifacts, caches, logs, or local environment files are committed.
- Dependencies are unchanged unless necessary and justified. - Dependencies are unchanged unless necessary and justified.
- Package boundaries are respected — shared types in `packages/core`, API wrappers in `packages/api-client`, app code in `apps/web`.
**Scope** **Scope**
- The change is scoped to the checklist item — no unrelated modifications. - The change is scoped to the checklist item — no unrelated modifications.
- Old implementation files (`server/`, `oebb-planner-app/`, `oebb-planner.jsx`) were not removed unless parity is tested and cleanup was explicitly requested. - Existing implementation files were not removed unless parity is tested and cleanup was explicitly requested.
**Accessibility (UI items only)** **Accessibility (UI items only)**
- Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states. - Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
@@ -56,21 +57,23 @@ Rules:
- Run the relevant test and build checks to confirm the implementation passes before marking ✔️: - Run the relevant test and build checks to confirm the implementation passes before marking ✔️:
```bash ```bash
npm run typecheck -w packages/core
npm run typecheck -w packages/api-client
npm run typecheck -w apps/web
npm run lint -w apps/web
npm run build -w apps/web
npm test npm test
npm run build
npm run typecheck
npm run lint
``` ```
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the rewrite agent to fix. - If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the implementation agent to fix.
## Completion Checklist ## Completion Checklist
Before marking a ✔️ box, confirm: Before marking a ✔️ box, confirm:
- The ✅ box for this item is already checked by the rewrite agent. - The ✅ box for this item is already checked by the implementation agent.
- All preceding phase items have both ✅ and ✔️ checked. - All preceding phase items have both ✅ and ✔️ checked.
- The implementation matches the intent in `REWRITE_PLAN.md`. - The implementation matches the intent in `FEATURES_PLAN.md`.
- Tests exist, are meaningful, and pass. - Tests exist, are meaningful, and pass.
- Build and type checks pass. - Build and type checks pass.
- No quality issues from the criteria above remain unresolved. - No quality issues from the criteria above remain unresolved.
+1070 -471
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle
+182
View File
@@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.floegger.timetoleave'
defaultConfig {
applicationId 'com.floegger.timetoleave'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
+31
View File
@@ -0,0 +1,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="exp+time-to-leave"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,61 @@
package com.floegger.timetoleave
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}
@@ -0,0 +1,56 @@
package com.floegger.timetoleave
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
<resources/>
@@ -0,0 +1,5 @@
<resources>
<color name="splashscreen_background">#FFFFFF</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">time-to-leave</string>
</resources>
@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>
+24
View File
@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
+65
View File
@@ -0,0 +1,65 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+39
View File
@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'time-to-leave'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
+18
View File
@@ -0,0 +1,18 @@
{
"expo": {
"extra": {
"eas": {
"projectId": "78e8f448-5ce1-4d2e-b589-481c67cb7aef"
}
},
"ios": {
"bundleIdentifier": "com.floegger.timetoleave",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "com.floegger.timetoleave"
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
.claude/
.idea/
.zed/
agent_loop
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
import * as Notifications from './src/services/expoNotifications';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
const initRef = useRef(false);
useEffect(() => {
// Run initialization only once
if (initRef.current) return;
initRef.current = true;
(async () => {
// Request notification permissions
await Notifications.requestPermissionsAsync();
// Set up notification handler (called exactly once)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
})();
}, []);
return <AppNavigator />;
}
+53
View File
@@ -0,0 +1,53 @@
{
"expo": {
"name": "Time To Leave",
"slug": "timetoleave",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#090816"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.timetoleave.app",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "This app uses your location to find nearby stations and calculate travel times.",
"NSUserNotificationUsageDescription": "This app uses notifications to remind you about events."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#090816"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.timetoleave.app",
"permissions": [
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.POST_NOTIFICATIONS",
"android.permission.INTERNET",
"android.permission.ACCESS_COARSE_LOCATION"
]
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-location",
"expo-notifications"
],
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy",
"extra": {
"eas": {
"projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec"
}
},
"owner": "floegger"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

+58
View File
@@ -0,0 +1,58 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">TimeToLeave app icon</title>
<desc id="desc">An icon-only TimeToLeave mark: a violet and magenta melting clock on a dark rounded-square background, flowing into a right arrow.</desc>
<defs>
<radialGradient id="iconBg" cx="50%" cy="36%" r="76%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="iconMelt" x1="205" y1="170" x2="840" y2="745" 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="iconGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="9" 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>
<style>
.iconStroke { stroke: url(#iconMelt); stroke-width: 56; stroke-linecap: round; stroke-linejoin: round; }
.iconTick { stroke: #F4F1EA; stroke-width: 16; stroke-linecap: round; opacity: 0.96; }
.iconHand { stroke: #F4F1EA; stroke-width: 22; stroke-linecap: round; }
</style>
</defs>
<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"/>
<path class="iconStroke" d="M821 668 C855 643 890 626 928 615"/>
<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"/>
<line class="iconTick" x1="704" y1="514" x2="737" y2="514"/>
<line class="iconTick" x1="512" y1="649" x2="512" y2="680"/>
<line class="iconTick" x1="388" y1="640" x2="411" y2="617"/>
<line class="iconTick" x1="318" y1="514" x2="351" y2="514"/>
<line class="iconTick" x1="388" y1="388" x2="411" y2="411"/>
</g>
<g>
<line class="iconHand" x1="512" y1="514" x2="512" y2="394"/>
<line class="iconHand" x1="512" y1="514" x2="631" y2="590"/>
<circle cx="512" cy="514" r="29" fill="#F4F1EA"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"cli": {
"version": ">= 10.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle",
"distribution": "store"
}
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "production"
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import js from "@eslint/js";
import ts from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// We have no .eslintrc, so we must define everything here.
export default ts.config(
{
ignores: ["dist/**"],
},
{
extends: [js.configs.recommended, ts.configs.recommended],
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
// React Native globals
__DEV__: "readonly",
alert: "readonly",
console: "readonly",
document: "readonly",
navigator: "readonly",
window: "readonly",
require: "readonly",
module: "readonly",
exports: "readonly",
process: "readonly",
jest: "readonly",
describe: "readonly",
it: "readonly",
test: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
tsconfigRootDir: path.resolve(__dirname),
},
},
plugins: {
react: reactPlugin,
},
rules: {
...js.configs.recommended.rules,
...ts.configs.recommended.rules,
// TypeScript handles type-related issues
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
// Allow _-prefixed parameters and variables to signal intentionally unused
"no-unused-vars": [
"warn",
{
varsIgnorePattern: "^_",
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
varsIgnorePattern: "^_",
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
// React Native uses require() heavily
"no-var-requires": "off",
// We use React Native's StyleSheet
"react/no-unknown-property": [
"error",
{
ignore: ["flex", "justifyContent", "alignItems", "width", "height", "margin", "padding"],
},
],
// Allow console.log for debugging
"no-console": "off",
},
},
);
+9
View File
@@ -0,0 +1,9 @@
import './src/polyfills/sharedArrayBuffer';
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
preset: 'jest-expo',
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
moduleNameMapper: {
'^@react-native-async-storage/async-storage$':
'@react-native-async-storage/async-storage/jest/async-storage-mock',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|@fortawesome/.*)',
],
};
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDefaultConfig } from 'expo/metro-config.js';
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.resolver.disableHierarchicalLookup = true;
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
config.resolver.extraNodeModules = {
react: path.resolve(projectRoot, 'node_modules/react'),
'react-test-renderer': path.resolve(projectRoot, 'node_modules/react-test-renderer'),
'react-native-safe-area-context': path.resolve(projectRoot, 'node_modules/react-native-safe-area-context'),
'react-native-screens': path.resolve(projectRoot, 'node_modules/react-native-screens'),
'@react-native-async-storage/async-storage': path.resolve(
projectRoot,
'node_modules/@react-native-async-storage/async-storage',
),
'expo-application': path.resolve(projectRoot, 'node_modules/expo-notifications/node_modules/expo-application'),
};
export default config;
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@timetoleave/mobile",
"version": "1.0.0",
"type": "module",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"lint": "eslint src/",
"test": "jest"
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-native-fontawesome": "^1.0.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.14.14",
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
"expo": "~54.0.34",
"expo-calendar": "~15.0.8",
"expo-dev-client": "~6.0.21",
"expo-location": "~19.0.8",
"expo-notifications": "~0.32.17",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-maps": "^1.20.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "29.5.14",
"@types/react": "~19.1.10",
"eslint-plugin-react": "^7.37.5",
"jest": "^29.7.0",
"jest-expo": "~54.0.0",
"react-test-renderer": "19.1.0",
"ts-jest": "^29.4.9",
"typescript": "~5.9.2",
"typescript-eslint": "^8.59.3"
},
"private": true
}
@@ -0,0 +1,87 @@
import {
setCachedJourneys,
getCachedJourneys,
setCachedBikeRoute,
getCachedBikeRoute,
setCachedWalkRoute,
getCachedWalkRoute,
clearApiCache,
} from '../store/apiCache';
import type { Journey, BikeRoute, WalkRoute } from '@timetoleave/core';
const mockJourneys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2099-01-01T08:00:00Z'),
rD: new Date('2099-01-01T08:00:00Z'),
sA: new Date('2099-01-01T09:00:00Z'),
rA: new Date('2099-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: false,
},
];
const mockBikeRoute: BikeRoute = {
distance: 5000,
duration: 1200,
steps: [{ name: 'Step 1', distance: 5000, duration: 1200, instruction: 'Ride' }],
};
const mockWalkRoute: WalkRoute = {
distance: 700,
duration: 600,
steps: [{ name: 'Step 1', distance: 700, duration: 600, instruction: 'Walk' }],
};
describe('apiCache', () => {
beforeEach(async () => {
await clearApiCache();
});
it('stores and retrieves journeys', async () => {
await setCachedJourneys('event-1', mockJourneys);
const retrieved = await getCachedJourneys('event-1');
expect(retrieved).toHaveLength(1);
expect(retrieved![0].id).toBe('journey-1');
expect(retrieved![0].rD).toEqual(new Date('2099-01-01T08:00:00Z'));
});
it('stores and retrieves bike routes', async () => {
await setCachedBikeRoute('event-1', mockBikeRoute);
const retrieved = await getCachedBikeRoute('event-1');
expect(retrieved).toEqual(mockBikeRoute);
});
it('stores and retrieves walk routes', async () => {
await setCachedWalkRoute('event-1', mockWalkRoute);
const retrieved = await getCachedWalkRoute('event-1');
expect(retrieved).toEqual(mockWalkRoute);
});
it('returns null for missing cache entries', async () => {
expect(await getCachedJourneys('missing')).toBeNull();
expect(await getCachedBikeRoute('missing')).toBeNull();
expect(await getCachedWalkRoute('missing')).toBeNull();
});
it('expires entries older than 30 minutes', async () => {
// Use jest fake timers to simulate 31 minutes passing
jest.useFakeTimers();
await setCachedBikeRoute('event-1', mockBikeRoute);
jest.advanceTimersByTime(31 * 60 * 1000);
const retrieved = await getCachedBikeRoute('event-1');
expect(retrieved).toBeNull();
jest.useRealTimers();
});
it('clears all cache entries', async () => {
await setCachedJourneys('event-1', mockJourneys);
await setCachedBikeRoute('event-2', mockBikeRoute);
await clearApiCache();
expect(await getCachedJourneys('event-1')).toBeNull();
expect(await getCachedBikeRoute('event-2')).toBeNull();
});
});
+158
View File
@@ -0,0 +1,158 @@
import * as Calendar from 'expo-calendar';
import { ensureCalendarPermission, fetchNativeEvents } from '../services/calendar';
// Mock expo-calendar
jest.mock('expo-calendar', () => ({
requestCalendarPermissionsAsync: jest.fn(),
isAvailableAsync: jest.fn(),
getCalendarsAsync: jest.fn(),
getEventsAsync: jest.fn(),
EntityTypes: {
EVENTS: 'EVENTS',
},
}));
const mockCalendar = Calendar as jest.Mocked<typeof Calendar>;
describe('calendar service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('ensureCalendarPermission', () => {
it('returns true when permission granted and calendar available', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
const result = await ensureCalendarPermission();
expect(result).toBe(true);
});
it('returns false when permission denied', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
const result = await ensureCalendarPermission();
expect(result).toBe(false);
expect(mockCalendar.isAvailableAsync).not.toHaveBeenCalled();
});
it('returns false when calendar not available', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(false);
const result = await ensureCalendarPermission();
expect(result).toBe(false);
});
});
describe('fetchNativeEvents', () => {
it('returns empty array when no permission', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toEqual([]);
});
it('returns empty array when no calendars', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toEqual([]);
});
it('returns mapped events from native calendar', async () => {
const startDate = new Date('2025-01-01');
const endDate = new Date('2025-01-31');
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([
{ id: 'cal1', title: 'Work' },
{ id: 'cal2', title: 'Personal' },
] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: 'Team Meeting',
location: 'Berlin',
startDate: new Date('2025-01-15T10:00:00'),
},
{
id: 'evt2',
calendarId: 'cal2',
title: 'Dentist',
location: null,
startDate: new Date('2025-01-20T14:00:00'),
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(startDate, endDate);
// Only the event with a location is returned; events without a location are filtered out
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: 'evt1',
title: 'Team Meeting',
destination: 'Berlin',
eventTime: new Date('2025-01-15T10:00:00'),
source: 'native:cal1',
});
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
['cal1', 'cal2'],
startDate,
endDate,
);
});
it('filters out events with no location', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: 'No Location Event',
location: null,
startDate: new Date('2025-01-15T10:00:00'),
},
{
id: 'evt2',
calendarId: 'cal1',
title: 'Empty Location Event',
location: ' ',
startDate: new Date('2025-01-16T10:00:00'),
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toHaveLength(0);
});
it('handles events with missing title', async () => {
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
mockCalendar.isAvailableAsync.mockResolvedValue(true);
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
mockCalendar.getEventsAsync.mockResolvedValue([
{
id: 'evt1',
calendarId: 'cal1',
title: null as unknown as string,
location: 'Wien Hbf',
startDate: null as unknown as string | Date,
},
] as Calendar.Event[]);
const result = await fetchNativeEvents(new Date(), new Date());
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Untitled Event');
expect(result[0].destination).toBe('Wien Hbf');
});
});
});
@@ -0,0 +1,143 @@
// Tests for core utilities
import { calculateCountdown, rankJourneys } from '@timetoleave/core';
import type { Journey } from '@timetoleave/core';
function journey(overrides: Partial<Journey>): Journey {
return {
id: 'journey',
sD: new Date('2025-01-01T10:00:00Z'),
rD: new Date('2025-01-01T10:00:00Z'),
sA: new Date('2025-01-01T11:00:00Z'),
rA: new Date('2025-01-01T11:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['REX1 -> Wr. Neustadt Hbf'],
cancelled: false,
...overrides,
};
}
describe('core utilities', () => {
describe('calculateCountdown', () => {
beforeEach(() => {
// Mock Date for consistent tests
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T12:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return urgent status for past events', () => {
const targetDate = new Date('2025-01-01T11:00:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('Now');
expect(result.color).toBe('red');
expect(result.urgent).toBe(true);
});
it('should return urgent status for events within 10 minutes', () => {
const targetDate = new Date('2025-01-01T12:05:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('5min');
expect(result.color).toBe('orange');
expect(result.urgent).toBe(true);
});
it('should return yellow for events within 30 minutes', () => {
const targetDate = new Date('2025-01-01T12:20:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('20min');
expect(result.color).toBe('yellow');
expect(result.urgent).toBe(false);
});
it('should return green for events within 60 minutes', () => {
const targetDate = new Date('2025-01-01T12:45:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('45min');
expect(result.color).toBe('green');
expect(result.urgent).toBe(false);
});
it('should return hours and minutes for events more than 1 hour away', () => {
const targetDate = new Date('2025-01-01T15:30:00Z');
const result = calculateCountdown(targetDate);
expect(result.label).toBe('3h 30min');
expect(result.color).toBe('blue');
expect(result.urgent).toBe(false);
});
it('should handle exact boundaries correctly', () => {
// Exactly 10 minutes
let targetDate = new Date('2025-01-01T12:10:00Z');
let result = calculateCountdown(targetDate);
expect(result.urgent).toBe(true);
// Exactly 30 minutes
targetDate = new Date('2025-01-01T12:30:00Z');
result = calculateCountdown(targetDate);
expect(result.urgent).toBe(false);
expect(result.color).toBe('yellow');
// Exactly 60 minutes
targetDate = new Date('2025-01-01T13:00:00Z');
result = calculateCountdown(targetDate);
expect(result.label).toBe('60min');
expect(result.color).toBe('green');
});
});
describe('rankJourneys', () => {
it('ranks the connection closest to the target arrival highest', () => {
const target = new Date('2025-01-01T11:00:00Z');
// All journeys have the same changes and similar duration so only
// arrival fit influences the ranking.
const early = journey({
id: 'early',
sD: new Date('2025-01-01T10:00:00Z'),
rD: new Date('2025-01-01T10:00:00Z'),
sA: new Date('2025-01-01T10:40:00Z'),
rA: new Date('2025-01-01T10:40:00Z'),
changes: 0,
});
const close = journey({
id: 'close',
sD: new Date('2025-01-01T10:00:00Z'),
rD: new Date('2025-01-01T10:00:00Z'),
sA: new Date('2025-01-01T10:58:00Z'),
rA: new Date('2025-01-01T10:58:00Z'),
changes: 0,
});
const late = journey({
id: 'late',
sD: new Date('2025-01-01T10:00:00Z'),
rD: new Date('2025-01-01T10:00:00Z'),
sA: new Date('2025-01-01T11:05:00Z'),
rA: new Date('2025-01-01T11:05:00Z'),
changes: 0,
});
const ranked = rankJourneys([early, late, close], target);
expect(ranked[0].journey.id).toBe('close');
});
it('uses directness and duration as tie breakers after arrival fit', () => {
const target = new Date('2025-01-01T11:00:00Z');
const oneChange = journey({ id: 'change', changes: 1 });
const direct = journey({ id: 'direct', changes: 0 });
const ranked = rankJourneys([oneChange, direct], target);
expect(ranked[0].journey.id).toBe('direct');
});
});
});
@@ -0,0 +1,242 @@
// Tests for event store persistence and behavior
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
loadEvents,
saveEvents,
addEvent,
removeEvent,
loadOriginStation,
saveOriginStation,
loadNotificationSettings,
saveNotificationSettings,
rescheduleAllNotifications
} from '../store/eventStore';
import { calculateLeaveByTime } from '../services/notifications';
import * as Notifications from '../services/expoNotifications';
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
}));
// Mock notification adapter
jest.mock('../services/expoNotifications', () => ({
getAllScheduledNotificationsAsync: jest.fn(),
cancelScheduledNotificationAsync: jest.fn(),
cancelAllScheduledNotificationsAsync: jest.fn(),
scheduleNotificationAsync: jest.fn(),
SchedulableTriggerInputTypes: {
DATE: 'date',
},
setNotificationHandler: jest.fn(),
}));
// Mock calculateLeaveByTime
jest.mock('../services/notifications', () => ({
...jest.requireActual('../services/notifications'),
calculateLeaveByTime: jest.fn(),
}));
describe('eventStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('events', () => {
it('should load empty events when none exist', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const events = await loadEvents();
expect(events).toEqual([]);
});
it('should load events from AsyncStorage', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockEvents));
const events = await loadEvents();
expect(events).toEqual(mockEvents);
expect(events[0].eventTime).toBeInstanceOf(Date);
});
it('should save events to AsyncStorage', async () => {
const events = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
}
];
await saveEvents(events);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
JSON.stringify(events)
);
});
it('should add event and schedule notification', async () => {
const event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('[]');
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
await addEvent(event);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
JSON.stringify([event])
);
});
it('should remove event and cancel notifications', async () => {
const event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify([event]));
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([
{
identifier: 'notif-1',
content: { data: { eventId: 'test-1' } }
}
]);
await removeEvent('test-1');
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_events',
'[]'
);
expect(Notifications.cancelScheduledNotificationAsync).toHaveBeenCalled();
});
});
describe('origin station', () => {
it('should load the default origin when no saved origin exists', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const station = await loadOriginStation();
expect(station).toEqual({
name: 'Goethegasse 36, 2340 Moedling',
extId: '1231701',
lat: 48.0806926,
lng: 16.2908052,
});
});
it('should load origin station from AsyncStorage', async () => {
const mockStation = {
extId: 'station-1',
name: 'Test Station',
lat: 48.2,
lng: 16.3,
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockStation));
const station = await loadOriginStation();
expect(station).toEqual(mockStation);
});
it('should save origin station to AsyncStorage', async () => {
const station = {
extId: 'station-1',
name: 'Test Station',
lat: 48.2,
lng: 16.3,
};
await saveOriginStation(station);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_origin',
JSON.stringify(station)
);
});
});
describe('notification settings', () => {
it('should load default settings when none exist', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const settings = await loadNotificationSettings();
expect(settings).toEqual({
bufferMinutes: 30,
enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
});
});
it('should load notification settings from AsyncStorage', async () => {
const mockSettings = {
bufferMinutes: 45,
enabled: false,
};
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockSettings));
const settings = await loadNotificationSettings();
expect(settings).toEqual(mockSettings);
});
it('should save notification settings to AsyncStorage', async () => {
const settings = {
bufferMinutes: 45,
enabled: false,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
};
await saveNotificationSettings(settings);
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'@timetoleave_notifications',
JSON.stringify(settings)
);
});
});
describe('rescheduleAllNotifications', () => {
it('should cancel all existing notifications and schedule new ones', async () => {
(AsyncStorage.getItem as jest.Mock)
.mockResolvedValueOnce(JSON.stringify([])) // events
.mockResolvedValueOnce(JSON.stringify({ bufferMinutes: 30, enabled: true })); // settings
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([]);
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
await rescheduleAllNotifications();
expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled();
// We can't easily verify scheduling due to complex mocks, but we can check it was called
});
});
});
@@ -0,0 +1,181 @@
// Tests for notification service
// Mock notification adapter before importing
jest.mock('../services/expoNotifications', () => ({
setNotificationHandler: jest.fn(),
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
cancelScheduledNotificationAsync: jest.fn().mockResolvedValue(undefined),
getAllScheduledNotificationsAsync: jest.fn().mockResolvedValue([]),
cancelAllScheduledNotificationsAsync: jest.fn().mockResolvedValue(undefined),
SchedulableTriggerInputTypes: {
DATE: 'date',
CALENDAR: 'calendar',
DAILY: 'daily',
WEEKLY: 'weekly',
MONTHLY: 'monthly',
YEARLY: 'yearly',
TIME_INTERVAL: 'timeInterval',
},
}));
import { calculateLeaveByTime } from '../services/notifications';
import type { Event, Journey } from '@timetoleave/core';
describe('notifications service', () => {
describe('calculateLeaveByTime', () => {
it('should calculate leave-by time from event time minus arrival buffer minus reminder buffer', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const leaveByTime = calculateLeaveByTime(event, [], 30, 5);
// Leave-by time should be 30 minutes before event time
// (arrival buffer) minus 5 minutes reminder buffer
// = event time - 35 minutes total
const expectedTime = new Date('2025-01-01T09:25:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should use earliest journey departure time if available', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: false,
},
{
id: 'journey-2',
sD: new Date('2025-01-01T07:00:00Z'),
rD: new Date('2025-01-01T07:00:00Z'),
sA: new Date('2025-01-01T08:00:00Z'),
rA: new Date('2025-01-01T08:00:00Z'),
delay: 0,
platform: '2',
changes: 1,
trains: ['U3', 'S2'],
cancelled: false,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// Should use earliest non-cancelled journey (journey-2 at 07:00)
// Leave-by time = journey departure (07:00) - reminder buffer (5 min)
const expectedTime = new Date('2025-01-01T06:55:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should skip cancelled journeys', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: true,
},
{
id: 'journey-2',
sD: new Date('2025-01-01T07:00:00Z'),
rD: new Date('2025-01-01T07:00:00Z'),
sA: new Date('2025-01-01T08:00:00Z'),
rA: new Date('2025-01-01T08:00:00Z'),
delay: 0,
platform: '2',
changes: 1,
trains: ['U3', 'S2'],
cancelled: false,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// Should use journey-2 since journey-1 is cancelled
// Leave-by time = journey departure (07:00) - reminder buffer (5 min)
const expectedTime = new Date('2025-01-01T06:55:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should fall back to event time minus buffer when all journeys cancelled', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const journeys: Journey[] = [
{
id: 'journey-1',
sD: new Date('2025-01-01T08:00:00Z'),
rD: new Date('2025-01-01T08:00:00Z'),
sA: new Date('2025-01-01T09:00:00Z'),
rA: new Date('2025-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1'],
cancelled: true,
},
];
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// All journeys cancelled, fall back to event time minus arrival buffer minus reminder buffer
// = 10:00 - 30 min - 5 min = 09:25
const expectedTime = new Date('2025-01-01T09:25:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
it('should handle zero buffer correctly', () => {
const event: Event = {
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2025-01-01T10:00:00Z'),
source: 'manual',
};
const leaveByTime = calculateLeaveByTime(event, [], 30, 0);
// With zero reminder buffer, leave-by time = event time - arrival buffer
// = 10:00 AM - 30 minutes = 09:30 AM
const expectedTime = new Date('2025-01-01T09:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
});
});
});
+293
View File
@@ -0,0 +1,293 @@
// Tests for UI screens
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { EventListScreen } from '../screens/EventListScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore';
import { calculateCountdown } from '@timetoleave/core';
// Mock the store and utilities
jest.mock('../store/eventStore', () => ({
loadEvents: jest.fn(),
loadOriginStation: jest.fn(),
loadNotificationSettings: jest.fn(),
removeEvent: jest.fn(),
}));
jest.mock('@timetoleave/core', () => ({
...jest.requireActual('@timetoleave/core'),
calculateCountdown: jest.fn(),
}));
jest.mock('../hooks/useColors', () => ({
useColors: () => ({
background: '#000',
card: '#111',
text: '#fff',
subtext: '#aaa',
accent: '#8B5CF6',
border: '#333',
delete: '#ff3b30',
error: '#ff3b30',
overlay: '#111',
}),
}));
jest.mock('../hooks/useDestinationStation', () => ({
useDestinationStation: () => ({
station: { name: 'Ziel Bahnhof', extId: '8103000', lat: 48.2, lng: 16.3 },
loading: false,
error: null,
}),
}));
jest.mock('../hooks/useGeocode', () => ({
useGeocode: () => ({
coords: { lat: 48.21, lng: 16.31, display_name: 'Test Destination' },
loading: false,
error: null,
}),
}));
jest.mock('../hooks/useWalkRoute', () => ({
useWalkRoute: () => ({
walkRoute: null,
loading: false,
error: null,
}),
}));
jest.mock('../hooks/useOriginStationWalk', () => ({
useOriginStationWalk: () => ({
station: { name: 'Mödling Bahnhof', extId: '1231701', lat: 48.085, lng: 16.296 },
walkRoute: { distance: 700, duration: 600, steps: [] },
loading: false,
error: null,
}),
}));
jest.mock('../services/api', () => ({
api: {
findStationByExtId: jest.fn().mockResolvedValue({
name: 'Mödling Bahnhof',
extId: '1231701',
lat: 48.085,
lng: 16.296,
}),
searchJourneys: jest.fn().mockResolvedValue([
{
id: 'journey-1',
sD: new Date('2099-01-01T08:00:00Z'),
rD: new Date('2099-01-01T08:00:00Z'),
sA: new Date('2099-01-01T09:00:00Z'),
rA: new Date('2099-01-01T09:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['S1 -> Wien'],
cancelled: false,
},
]),
},
}));
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useFocusEffect: (callback: () => void) => {
const React = jest.requireActual('react');
React.useEffect(() => {
callback();
}, [callback]);
},
}));
// --- Mock navigation factories ---
type RootStack = {
EventList: undefined;
EventDetail: { eventId: string };
AddEvent: undefined;
Settings: undefined;
CalendarImport: undefined;
};
function createMockNavigationProp<
S extends Record<string, undefined | Record<string, unknown>>,
T extends keyof S
>(overrides?: Partial<NativeStackNavigationProp<S, T>>): NativeStackNavigationProp<S, T> {
const mocks: Partial<NativeStackNavigationProp<S, T>> = {
navigate: jest.fn(),
dispatch: jest.fn(() => {}),
goBack: jest.fn(),
isFocused: jest.fn(() => true),
setParams: jest.fn(),
setOptions: jest.fn(),
reset: jest.fn(),
pop: jest.fn(),
preload: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
canGoBack: jest.fn(() => false),
...overrides,
};
return mocks as NativeStackNavigationProp<S, T>;
}
const mockRouteEventList = { name: 'EventList' as const, params: undefined } as unknown as RouteProp<RootStack, 'EventList'>;
const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as unknown as RouteProp<RootStack, 'AddEvent'>;
// --- End mock factories ---
describe('EventListScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
(loadOriginStation as jest.Mock).mockResolvedValue({
name: 'Mödling Bahnhof',
extId: '1231701',
lat: 48.08,
lng: 16.29,
});
(loadNotificationSettings as jest.Mock).mockResolvedValue({
bufferMinutes: 30,
enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
});
});
it('should render empty state when no events', async () => {
(loadEvents as jest.Mock).mockResolvedValue([]);
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('No upcoming events')).toBeTruthy();
});
});
it('should render events when they exist', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2099-01-01T10:00:00Z'),
source: 'manual',
}
];
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
(calculateCountdown as jest.Mock).mockReturnValue({
label: '30min',
color: 'green',
urgent: false
});
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('Test Event')).toBeTruthy();
expect(getByText('Test Destination')).toBeTruthy();
});
});
it('should handle refresh correctly', async () => {
const mockEvents = [
{
id: 'test-1',
title: 'Test Event',
destination: 'Test Destination',
eventTime: new Date('2099-01-01T10:00:00Z'),
source: 'manual',
}
];
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
(calculateCountdown as jest.Mock).mockReturnValue({
label: '30min',
color: 'green',
urgent: false
});
const { getByText } = render(
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
);
await waitFor(() => {
expect(getByText('Test Event')).toBeTruthy();
});
});
});
describe('AddEventScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render form correctly', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
expect(getByPlaceholderText('e.g. Team Meeting')).toBeTruthy();
expect(getByPlaceholderText('e.g. Technikum Wien')).toBeTruthy();
expect(getByPlaceholderText('YYYY-MM-DD')).toBeTruthy();
expect(getByPlaceholderText('HH:MM')).toBeTruthy();
expect(getByText('Save')).toBeTruthy();
expect(getByText('Cancel')).toBeTruthy();
});
it('should show validation errors', () => {
const { getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Try to save without filling form
const saveButton = getByText('Save');
fireEvent.press(saveButton);
// Should show error text
expect(getByText('Title required')).toBeTruthy();
});
it('should validate date format', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Fill in all required fields except date format is invalid
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), 'invalid-date');
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
const saveButton = getByText('Save');
fireEvent.press(saveButton);
expect(getByText('Invalid date')).toBeTruthy();
});
it('should validate future date', () => {
const { getByPlaceholderText, getByText } = render(
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
);
// Fill in all required fields with a past date
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), '2020-01-01');
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
const saveButton = getByText('Save');
fireEvent.press(saveButton);
expect(getByText('Date must be in the future')).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
declare module '*.png' {
const value: number;
export default value;
}
@@ -0,0 +1,72 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faClock, faRuler } from '@fortawesome/free-solid-svg-icons';
import { formatDuration, formatDistance } from '@timetoleave/core';
import type { BikeRoute, Station } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the bike route information section shown on event detail. */
interface Props {
bikeRoute: BikeRoute | null;
loading: boolean;
origin: Station | null;
colors: AppColors;
}
/**
* Displays cycling route duration, distance, and an interactive map for the
* event's origin → destination leg. Shows contextual empty states when no
* origin is set or no route is available.
*/
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Bike route</Text>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Loading bike route</Text>
</View>
) : bikeRoute ? (
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.row}>
<View style={styles.labelRow}>
<FontAwesomeIcon icon={faClock} size={13} color={colors.text} />
<Text style={[styles.label, { color: colors.text }]}>Dauer</Text>
</View>
<Text style={[styles.value, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
</View>
<View style={styles.row}>
<View style={styles.labelRow}>
<FontAwesomeIcon icon={faRuler} size={13} color={colors.text} />
<Text style={[styles.label, { color: colors.text }]}>Distanz</Text>
</View>
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
</View>
{bikeRoute.geometry && (
<RouteMap geometry={bikeRoute.geometry} colors={colors} mode="bike" />
)}
</View>
) : (
<Text style={[styles.empty, { color: colors.subtext }]}>
{origin ? 'No bike route available' : 'Set origin station'}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
centered: { alignItems: 'center', gap: 8 },
hint: { fontSize: 15, marginTop: 12 },
empty: { fontSize: 14 },
card: { borderRadius: 10, padding: 14, borderWidth: 1 },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 6 },
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
label: { fontSize: 15, fontWeight: '500' },
value: { fontSize: 15, fontWeight: '600' },
});
@@ -0,0 +1,76 @@
import { StyleSheet, Text, View } from 'react-native';
import type { Event } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
/** Props for the event detail header showing title, destination, times, and buffers. */
interface Props {
event: Event;
leaveByTime: Date | null;
arrivalBufferMinutes: number;
colors: AppColors;
}
/**
* Renders the event title, destination, leave-by time, data source, and a
* three-column grid with leave-by time, arrive-by time, and buffer.
*/
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
<Text style={[styles.leaveTime, { color: leaveByTime ? colors.accent : colors.subtext }]}>
{leaveByTime
? `Losgehen um ${leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}`
: 'Losgehzeit wird berechnet'}
</Text>
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
<View style={[styles.infoGrid, { borderTopColor: colors.border }]}>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{leaveByTime
? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })
: '—'}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>
{arriveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>{arrivalBufferMinutes} min</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20, marginBottom: 12 },
title: { fontSize: 22, fontWeight: '700' },
destination: { fontSize: 16, marginTop: 4 },
leaveTime: { fontSize: 18, fontWeight: '700', marginTop: 10 },
source: { fontSize: 12, marginTop: 4 },
infoGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
},
infoBox: { alignItems: 'center' },
infoLabel: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 1,
},
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
});
+170
View File
@@ -0,0 +1,170 @@
import { useMemo, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { formatDuration, formatDistance, rankJourneys } from '@timetoleave/core';
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
import { RouteMap } from './RouteMap';
/** Props for the train journey list and optional final walk leg. */
interface Props {
journeys: Journey[];
destStationLoading: boolean;
walkRoute: WalkRoute | null;
loadingWalk: boolean;
showWalkingOption: boolean;
eventTime: Date;
arrivalBufferMinutes: number;
origin: Station | null;
colors: AppColors;
}
/**
* Renders each journey card with train lines, departure/arrival times, platform,
* transfer count, and delay/cancellation badges. Cards that arrive too late are
* visually dimmed and bordered in red. Optionally shows the final walking leg
* from the destination station to the event location.
*/
export function JourneyList({
journeys,
destStationLoading,
walkRoute,
loadingWalk,
showWalkingOption,
eventTime,
arrivalBufferMinutes,
origin,
colors,
}: Props) {
const [expanded, setExpanded] = useState(false);
const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0;
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000);
const rankedJourneys = useMemo(
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
[journeys, targetArrivalTime, walkDurationMs],
);
const visibleJourneys = expanded ? rankedJourneys : rankedJourneys.slice(0, 1);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Train connections</Text>
{destStationLoading && (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Resolving destination station</Text>
</View>
)}
{journeys.length === 0 && !destStationLoading ? (
<Text style={[styles.empty, { color: colors.subtext }]}>
{origin ? 'No connections found' : 'Set origin station'}
</Text>
) : (
<TouchableOpacity
activeOpacity={rankedJourneys.length > 1 ? 0.75 : 1}
onPress={() => rankedJourneys.length > 1 && setExpanded((current) => !current)}
accessibilityRole={rankedJourneys.length > 1 ? 'button' : undefined}
accessibilityLabel={expanded ? 'Show fewer train connections' : 'Show all train connections'}
>
{visibleJourneys.map(({ journey: j }, index) => {
const finalArrival = new Date(j.rA.getTime() + walkDurationMs);
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
const durationMinutes = Math.max(0, Math.round((j.rA.getTime() - j.rD.getTime()) / 60_000));
return (
<View
key={j.id}
style={[
styles.card,
{ backgroundColor: colors.card, borderColor: arrivesTooLate ? colors.error : colors.border },
arrivesTooLate && styles.lateCard,
]}
>
<View style={styles.row}>
<Text style={[styles.line, { color: colors.text }]} numberOfLines={2}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{index === 0 && (
<Text style={[styles.bestBadge, { backgroundColor: colors.accent }]}>Top</Text>
)}
{j.delay > 0 && (
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
)}
{j.cancelled && (
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Cancelled</Text>
)}
</View>
<Text style={[styles.detail, { color: colors.text }]}>
Departure: {new Date(j.sD).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{' '}(Platform {j.platform || '—'})
</Text>
<Text style={[styles.detail, { color: colors.subtext }]}>
Arrival: {new Date(j.sA).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{' '}({j.changes === 0 ? 'Direct' : `${j.changes} tr.`})
</Text>
<Text style={[styles.detail, { color: colors.subtext }]}>
Duration: {durationMinutes} min
</Text>
{walkDurationMs > 0 && (
<Text style={[styles.detail, { color: arrivesTooLate ? colors.error : colors.subtext }]}>
Arrive: {finalArrival.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
{arrivesTooLate ? ' (too late)' : ''}
</Text>
)}
</View>
);
})}
{rankedJourneys.length > 1 && (
<Text style={[styles.expandHint, { color: colors.accent }]}>
{expanded ? 'Show fewer connections' : `Show ${rankedJourneys.length - 1} more connections`}
</Text>
)}
</TouchableOpacity>
)}
{showWalkingOption && walkRoute && (
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border, marginTop: 10 }]}>
<Text style={[styles.walkTitle, { color: colors.text }]}>Final walk</Text>
<View style={styles.row}>
<Text style={[styles.walkLabel, { color: colors.text }]}>Duration</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
</View>
<View style={styles.row}>
<Text style={[styles.walkLabel, { color: colors.text }]}>Distance</Text>
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
</View>
{walkRoute.geometry && (
<RouteMap geometry={walkRoute.geometry} colors={colors} mode="walk" />
)}
</View>
)}
{showWalkingOption && loadingWalk && (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Loading walk route</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
centered: { alignItems: 'center', gap: 8 },
hint: { fontSize: 15, marginTop: 12 },
empty: { fontSize: 14 },
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
lateCard: { opacity: 0.55 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
line: { flex: 1, fontSize: 16, fontWeight: '600' },
bestBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, overflow: 'hidden' },
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
detail: { fontSize: 13, marginTop: 6 },
expandHint: { fontSize: 13, fontWeight: '600', marginTop: 2, marginBottom: 12, textAlign: 'center' },
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
walkLabel: { fontSize: 14, fontWeight: '500' },
walkValue: { fontSize: 14, fontWeight: '600' },
});
@@ -0,0 +1,83 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faBusSimple } from '@fortawesome/free-solid-svg-icons';
import type { WienerLinienStop } from '@timetoleave/core';
import type { DepartureRow } from '../hooks/useWienerLinien';
import type { AppColors } from '../hooks/useColors';
/** Props for the nearby public transport stops section. */
interface Props {
stops: WienerLinienStop[];
departures: DepartureRow[];
loading: boolean;
error: string | null;
colors: AppColors;
}
/**
* Shows public transport stops near the event destination and their upcoming
* departures. Caps the departure list at 8 items to avoid overwhelming the UI.
* Falls back to showing plain stop names when no departure data is available.
*/
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
if (!loading && stops.length === 0 && !error) return null;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.sectionTitleRow}>
<FontAwesomeIcon icon={faBusSimple} size={16} color={colors.text} />
<Text style={[styles.sectionTitle, { color: colors.text }]}>Public transit near destination</Text>
</View>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="small" color={colors.accent} />
<Text style={[styles.hint, { color: colors.subtext }]}>Loading stops</Text>
</View>
) : departures.length > 0 ? (
departures.slice(0, 8).map((dep, i) => (
<View
key={`${dep.stopId}-${dep.lineName}-${i}`}
style={[styles.depCard, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<View style={styles.depRow}>
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
</View>
<Text style={[styles.direction, { color: colors.text }]} numberOfLines={1}>
{dep.direction}
</Text>
<Text style={[styles.minutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
{dep.minutes === 0 ? 'now' : `${dep.minutes} min`}
</Text>
</View>
</View>
))
) : (
stops.slice(0, 5).map((stop) => (
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
</View>
))
)}
{error && <Text style={[styles.hint, { color: colors.subtext }]}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
sectionTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
sectionTitle: { fontSize: 18, fontWeight: '600' },
centered: { alignItems: 'center', gap: 8 },
hint: { fontSize: 14 },
depCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
depRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' },
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
direction: { flex: 1, fontSize: 13 },
minutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' },
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
stopName: { fontSize: 14, fontWeight: '500' },
});
+85
View File
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import MapView, { Marker, Polyline } from 'react-native-maps';
import { decodePolyline } from '../utils/polyline';
import type { AppColors } from '../hooks/useColors';
interface Props {
geometry: string;
colors: AppColors;
mode: 'bike' | 'walk';
}
function getRegionFromCoordinates(
coords: Array<{ latitude: number; longitude: number }>,
) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const c of coords) {
minLat = Math.min(minLat, c.latitude);
maxLat = Math.max(maxLat, c.latitude);
minLng = Math.min(minLng, c.longitude);
maxLng = Math.max(maxLng, c.longitude);
}
const latDelta = (maxLat - minLat) * 1.3; // 30 % padding
const lngDelta = (maxLng - minLng) * 1.3;
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLng + maxLng) / 2,
latitudeDelta: Math.max(latDelta, 0.005),
longitudeDelta: Math.max(lngDelta, 0.005),
};
}
/**
* Renders an interactive map with the decoded route polyline and start/end
* markers. Automatically centres and zooms to fit the entire route.
*/
export function RouteMap({ geometry, colors, mode }: Props) {
const coords = useMemo(() => decodePolyline(geometry), [geometry]);
const region = useMemo(() => {
if (coords.length < 2) return null;
return getRegionFromCoordinates(coords);
}, [coords]);
if (!region || coords.length < 2) {
return null;
}
const strokeColor = mode === 'bike' ? colors.accent : colors.success;
const startCoord = coords[0];
const endCoord = coords[coords.length - 1];
return (
<View style={[styles.container, { borderColor: colors.border }]}>
<MapView style={styles.map} initialRegion={region} scrollEnabled={false} zoomEnabled={false} rotateEnabled={false} pitchEnabled={false}>
<Polyline
coordinates={coords}
strokeColor={strokeColor}
strokeWidth={4}
/>
<Marker coordinate={startCoord} pinColor={colors.accent} />
<Marker coordinate={endCoord} pinColor={colors.success} />
</MapView>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: 10,
height: 220,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 1,
},
map: {
...StyleSheet.absoluteFillObject,
},
});
+45
View File
@@ -0,0 +1,45 @@
import { useTheme } from './useTheme';
const DARK = {
background: '#090816',
card: '#17112A',
text: '#F4F1EA',
subtext: 'rgba(244,241,234,0.5)',
accent: '#8B5CF6',
border: '#38383a',
error: '#ff453a',
success: '#30d158',
warning: '#ff9f0a',
purple: '#B23CFF',
delete: '#FF3B30',
overlay: 'rgba(28,28,30,0.95)',
highlight: '#1a3a5c',
} as const;
const LIGHT = {
background: '#f2f2f7',
card: '#ffffff',
text: '#1c1c1e',
subtext: '#8e8e93',
accent: '#B23CFF',
border: '#e5e5ea',
error: '#FF3B30',
success: '#34C759',
warning: '#FF9500',
purple: '#8B5CF6',
delete: '#FF3B30',
overlay: 'rgba(255,255,255,0.95)',
highlight: '#e8f4fd',
} as const;
/** Color token type — every theme variant shares the same keys. */
export type AppColors = Record<keyof typeof DARK, string>;
/**
* Returns the full color palette (dark or light) based on the current theme.
* Derives from {@link useTheme} so it stays in sync with user preferences.
*/
export function useColors(): AppColors {
const { dark } = useTheme();
return dark ? DARK : LIGHT;
}
+87
View File
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import type { Journey } from '@timetoleave/core';
interface DepartureTimeResult {
departureTime: Date | null;
arrivalTime: Date | null;
mode: 'train' | 'bike' | null;
}
/**
* Calculate departure time based on selected transport mode.
*
* For train mode, picks the latest-departing journey that still arrives on time
* (factoring in the final walking leg). For bike mode, simply subtracts the
* cycling duration from the target arrival time.
*
* Mirrors the web app's useDepartureTime hook.
*
* @param eventTime - The scheduled event start time.
* @param journeys - Available train journeys (may be null if not loaded yet).
* @param bikeDurationSeconds - Cycling duration in seconds, or null if unavailable.
* @param activeMode - The currently selected transport mode.
* @param arrivalBufferMinutes - Minutes to arrive before the event starts.
* @param trainWalkDurationSeconds - Walking time from destination station to event (seconds).
* @param originWalkDurationSeconds - Walking time from start point to origin station (seconds).
*/
export function useDepartureTime(
eventTime: Date,
journeys: Journey[] | null,
bikeDurationSeconds: number | null,
activeMode: 'train' | 'bike' | null,
arrivalBufferMinutes: number,
trainWalkDurationSeconds = 0,
originWalkDurationSeconds = 0,
): DepartureTimeResult {
return useMemo(() => {
// Calculate target arrival time (event time minus buffer)
const targetArrivalTime = new Date(eventTime);
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
// Filter out cancelled journeys
const validJourneys = journeys?.filter((journey) => !journey.cancelled) || [];
let departureTime: Date | null = null;
let arrivalTime: Date | null = null;
let mode: 'train' | 'bike' | null = null;
if (activeMode === 'train' && validJourneys.length > 0) {
// Find journeys that arrive by target time
const walkDurationMs = trainWalkDurationSeconds * 1000;
const onTimeJourneys = validJourneys.filter(
(journey) => journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime(),
);
if (onTimeJourneys.length > 0) {
// Pick the journey with the latest departure that still arrives on time
const bestJourney = onTimeJourneys.reduce((latest, current) =>
current.rD.getTime() > latest.rD.getTime() ? current : latest,
);
departureTime = new Date(bestJourney.rD.getTime() - originWalkDurationSeconds * 1000);
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
mode = 'train';
}
}
if (activeMode === 'bike' && bikeDurationSeconds !== null && bikeDurationSeconds > 0) {
const bikeDurationMs = bikeDurationSeconds * 1000;
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
const targetArrivalMs = eventTime.getTime() - totalBufferMs;
departureTime = new Date(targetArrivalMs - bikeDurationMs);
arrivalTime = new Date(targetArrivalMs);
mode = 'bike';
}
return { departureTime, arrivalTime, mode };
}, [
eventTime,
journeys,
bikeDurationSeconds,
activeMode,
arrivalBufferMinutes,
trainWalkDurationSeconds,
originWalkDurationSeconds,
]);
}
@@ -0,0 +1,113 @@
import { useState, useEffect } from 'react';
import type { Station } from '@timetoleave/core';
import { api } from '../services/api';
/**
* Geocodes a destination string to the nearest HAFAS station.
*
* Two-step process: first geocodes the address to lat/lng via Nominatim,
* then sends those coordinates to HAFAS LocMatch to find the closest station.
* Debounces lookups by 400 ms to avoid excessive API calls while typing.
*/
/** Intermediate shape returned by HAFAS LocMatch before we pick the best station. */
interface HafasLocation {
type: string;
name: string;
extId: string;
lat?: number;
lon?: number;
crd?: {
x?: number;
y?: number;
};
}
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
if (value == null) return undefined;
return Math.abs(value) > 1000 ? value / 1e6 : value;
}
export function useDestinationStation(destination: string | undefined) {
const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination?.trim()) {
setStation(null);
return;
}
let isMounted = true;
// Debounce the lookup
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
try {
// First geocode the destination to get coordinates
const geocodeResults = await api.geocode(destination, 'at');
const coords = geocodeResults[0];
if (!coords) {
if (isMounted) {
setStation(null);
setLoading(false);
}
return;
}
// Then use HAFAS LocMatch to find the nearest station
const body = {
svcReqL: [
{
meth: 'LocMatch',
req: {
input: {
loc: {
crd: {
x: Math.round(coords.lng * 1e6),
y: Math.round(coords.lat * 1e6),
},
type: 'S',
},
maxLoc: 1,
field: 'S',
},
},
},
],
};
const data = await api.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(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,
lat: normalizeHafasCoordinate(l.lat ?? l.crd?.y),
lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x),
}));
if (!isMounted) return;
setStation(stations[0] ?? null);
setLoading(false);
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Station lookup failed');
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
};
}, [destination]);
return { station, loading, error };
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import type { GeocodeResult } from '@timetoleave/core';
import { api } from '../services/api';
/**
* Geocode a destination name to coordinates.
* Debounces API calls by 400 ms. Automatically resets to null when the
* destination is cleared or becomes empty.
* Mirrors the web app's useGeocode hook.
*/
export function useGeocode(destination: string | undefined) {
const [coords, setCoords] = useState<GeocodeResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination?.trim()) {
setCoords(null);
return;
}
let isMounted = true;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
try {
const results = await api.geocode(destination, 'at');
if (isMounted) {
setCoords(results[0] ?? null);
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Geocoding failed');
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
};
}, [destination]);
return { coords, loading, error };
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import type { Station } from '@timetoleave/core';
import { api } from '../services/api';
import { useWalkRoute } from './useWalkRoute';
/**
* Resolve the walking leg from the saved origin point to the departure station.
*
* `origin.lat/lng` may represent the user's real start point while `origin.extId`
* identifies the station used for train search. When coordinates are missing or
* station resolution fails, callers can safely fall back to zero duration.
*/
export function useOriginStationWalk(origin: Station | null) {
const [station, setStation] = useState<Station | null>(null);
const [lookupError, setLookupError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const resolveStation = async () => {
setStation(null);
setLookupError(null);
if (origin?.lat == null || origin.lng == null) return;
try {
const selectedStation = await api.findStationByExtId(origin.extId);
if (!isMounted) return;
setStation(selectedStation);
} catch (err) {
if (!isMounted) return;
setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed');
}
};
resolveStation();
return () => {
isMounted = false;
};
}, [origin?.lat, origin?.lng]);
const walk = useWalkRoute(origin?.lat, origin?.lng, station?.lat, station?.lng);
return {
station,
walkRoute: walk.walkRoute,
loading: walk.loading,
error: lookupError ?? walk.error,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
/** Theme storage key in AsyncStorage. */
const THEME_KEY = '@timetoleave_theme';
type Theme = 'dark' | 'light';
/** Returns the default theme. Mobile defaults to dark to match the web app. */
function getDefaultTheme(): Theme {
// React Native doesn't have window.matchMedia, but we can use a simple default
// The web app uses a dark-first theme, so we match that default
return 'dark';
}
/**
* Theme management for the mobile app.
*
* Persists the user's choice in AsyncStorage and initializes from storage
* on mount (guarded by a ref so hot-reload doesn't re-read). Returns a
* `dark` boolean and a `toggle` callback for switching themes.
*
* Mirrors the web app's useTheme hook.
*/
export function useTheme() {
const [dark, setDark] = useState(false);
const initialized = useRef(false);
// Load theme on mount
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
(async () => {
try {
const stored = await AsyncStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') {
setDark(stored === 'dark');
} else {
setDark(getDefaultTheme() === 'dark');
}
} catch {
setDark(false);
}
})();
}, []);
const toggle = useCallback(() => {
setDark((prev) => {
const next = !prev;
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {
// Silently fail storage
});
return next;
});
}, []);
return { dark, toggle };
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import type { WalkRoute } from '@timetoleave/core';
import { api } from '../services/api';
/**
* Fetch walk route between two points using the OSRM walking router.
* Skips the request if any coordinate is missing, resetting state to null.
* Mirrors the web app's useWalkRoute hook.
*/
export function useWalkRoute(
fromLat: number | undefined,
fromLng: number | undefined,
toLat: number | undefined,
toLng: number | undefined,
) {
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchRoute = async () => {
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
setWalkRoute(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const data = await api.getWalkRoute(fromLat, fromLng, toLat, toLng);
if (isMounted) {
setWalkRoute(data);
setLoading(false);
}
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : 'Failed to fetch walk route';
setError(message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchRoute();
return () => {
isMounted = false;
};
}, [fromLat, fromLng, toLat, toLng]);
return { walkRoute, loading, error };
}
+122
View File
@@ -0,0 +1,122 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
import { api } from '../services/api';
/** Flattened departure row shown in the NearbyStops UI. */
export interface DepartureRow {
stopId: string;
lineName: string;
direction: string;
minutes: number;
}
const DEBOUNCE_MS = 400;
const REFRESH_INTERVAL_MS = 60_000;
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
return {
stopId: dep.stopId,
lineName: dep.line.name,
direction: dep.direction,
minutes,
};
}
/**
* Fetches nearby WienerLinien stops around given coordinates and their live
* departures. Debounces initial lookups (400 ms) and refreshes departures
* every 60 seconds. Uses a cancellation ref to prevent stale overwrites when
* coordinates change mid-request.
*
* @param lat - Latitude of the point of interest.
* @param lng - Longitude of the point of interest.
* @param radius - Search radius in meters (defaults to 500).
*/
export function useWienerLinien(
lat: number | undefined,
lng: number | undefined,
radius?: number,
) {
const [stops, setStops] = useState<WienerLinienStop[]>([]);
const [departures, setDepartures] = useState<DepartureRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stopped by the cleanup effect when coordinates change, so in-flight
// monitor requests don't overwrite stale stop lists.
const stopIdsRef = useRef<string[]>([]);
const cancelledRef = useRef(false);
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
if (stopIds.length === 0 || cancelledRef.current) return;
try {
const rawDepartures = await api.monitorStops(stopIds);
if (!cancelledRef.current) {
setDepartures(rawDepartures.map(transformDeparture));
}
} catch {
// Silently ignore monitor errors — stops are still shown
}
}, []);
useEffect(() => {
cancelledRef.current = false;
if (lat === undefined || lng === undefined) {
setStops([]);
setDepartures([]);
setError(null);
setLoading(false);
return;
}
const debounceTimer = setTimeout(async () => {
if (cancelledRef.current) return;
setLoading(true);
setError(null);
try {
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
if (cancelledRef.current) return;
setStops(stopsList);
setLoading(false);
const ids = stopsList.map((s) => s.id);
stopIdsRef.current = ids;
await fetchMonitor(ids);
} catch (err) {
if (cancelledRef.current) return;
setError(err instanceof Error ? err.message : 'Stops could not be loaded');
setLoading(false);
}
}, DEBOUNCE_MS);
return () => {
cancelledRef.current = true;
clearTimeout(debounceTimer);
};
}, [lat, lng, radius, fetchMonitor]);
// Periodic departures refresh
useEffect(() => {
if (stops.length === 0) return;
const intervalId = setInterval(() => {
const ids = stopIdsRef.current;
if (ids.length > 0) {
fetchMonitor(ids);
}
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [stops.length, fetchMonitor]);
return { stops, departures, loading, error };
}
+148
View File
@@ -0,0 +1,148 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faBars } from '@fortawesome/free-solid-svg-icons/faBars';
import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { EventListScreen } from '../screens/EventListScreen';
import { EventDetailScreen } from '../screens/EventDetailScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
import type { RootStack } from '../types/navigation';
import navLogo from '../../assets/nav-logo.png';
// ── Root Stack ──
const Root = createNativeStackNavigator<RootStack>();
const byPrefixAndName = { fas: { bars: faBars } };
type HeaderMenuProps = {
navigation: {
navigate: (_screen: 'CalendarImport' | 'Settings') => void;
};
};
function HeaderMenu({ navigation }: HeaderMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const navigateTo = (screen: 'CalendarImport' | 'Settings') => {
setIsOpen(false);
navigation.navigate(screen);
};
return (
<>
<Pressable
accessibilityLabel="Open menu"
accessibilityRole="button"
hitSlop={12}
onPress={() => setIsOpen(true)}
style={({ pressed }) => [styles.menuButton, pressed && styles.menuButtonPressed]}
>
<FontAwesomeIcon icon={byPrefixAndName.fas['bars']} color="#F4F1EA" size={22} />
</Pressable>
<Modal animationType="fade" transparent visible={isOpen} onRequestClose={() => setIsOpen(false)}>
<Pressable style={styles.menuOverlay} onPress={() => setIsOpen(false)}>
<View style={styles.menuPanel}>
<Pressable
accessibilityRole="menuitem"
onPress={() => navigateTo('CalendarImport')}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
>
<Text style={styles.menuItemText}>Calendar</Text>
</Pressable>
<Pressable
accessibilityRole="menuitem"
onPress={() => navigateTo('Settings')}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
>
<Text style={styles.menuItemText}>Settings</Text>
</Pressable>
</View>
</Pressable>
</Modal>
</>
);
}
/**
* Root native stack navigator for the app.
* Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
*/
export default function AppNavigator() {
return (
<SafeAreaProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: '#090816' }}>
<StatusBar style="light" />
<NavigationContainer>
<Root.Navigator
initialRouteName="EventList"
screenOptions={({ navigation }) => ({
headerStyle: { backgroundColor: '#17112A' },
headerTintColor: '#F4F1EA',
headerRight: () => <HeaderMenu navigation={navigation} />,
headerTitle: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Image source={navLogo} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="contain" />
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time</Text>
<Text style={{ color: '#ea1579', fontSize: 18, fontWeight: '600' }}>To </Text>
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Leave</Text>
</View>
),
})}
>
<Root.Screen name="EventList" component={EventListScreen} />
<Root.Screen name="AddEvent" component={AddEventScreen} />
<Root.Screen name="EventDetail" component={EventDetailScreen} />
<Root.Screen name="Settings" component={SettingsScreen} />
<Root.Screen name="CalendarImport" component={CalendarImportScreen} />
</Root.Navigator>
</NavigationContainer>
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
menuButton: {
alignItems: 'center',
height: 40,
justifyContent: 'center',
width: 40,
},
menuButtonPressed: {
opacity: 0.65,
},
menuOverlay: {
alignItems: 'flex-end',
backgroundColor: 'rgba(9, 8, 22, 0.45)',
flex: 1,
paddingRight: 12,
paddingTop: 72,
},
menuPanel: {
backgroundColor: '#17112A',
borderColor: '#3B3157',
borderRadius: 8,
borderWidth: 1,
minWidth: 168,
overflow: 'hidden',
},
menuItem: {
paddingHorizontal: 18,
paddingVertical: 14,
},
menuItemPressed: {
backgroundColor: '#2A2140',
},
menuItemText: {
color: '#F4F1EA',
fontSize: 16,
fontWeight: '600',
},
});
@@ -0,0 +1,94 @@
/**
* Polyfills for newer JavaScript features that React Native's Hermes engine
* doesn't provide out of the box.
*
* - `String.prototype.toWellFormed` / `isWellFormed` — handles lone surrogates
* by replacing them with the Unicode replacement character (U+FFFD).
* - `ArrayBuffer.prototype.resizable` — required by newer Intl APIs.
* - `SharedArrayBuffer` — stubbed so that libraries which check for its
* presence (e.g. the Intl locale data) don't crash at runtime.
*/
const globalScope = globalThis as Record<string, unknown>;
const stringPrototype = String.prototype as typeof String.prototype & {
isWellFormed?: () => boolean;
toWellFormed?: () => string;
};
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
/**
* Replace lone surrogates (U+D800…U+DBFF without a partner, or U+DC00…U+DFFF
* without a lead) with the Unicode replacement character U+FFFD. Valid
* surrogate pairs are kept as-is. Used by both `toWellFormed` and `isWellFormed`.
*/
function toWellFormedString(value: string): string {
let result = '';
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
result += value[index] + value[index + 1];
index += 1;
} else {
result += '\uFFFD';
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
result += '\uFFFD';
} else {
result += value[index];
}
}
return result;
}
if (typeof stringPrototype.toWellFormed !== 'function') {
Object.defineProperty(String.prototype, 'toWellFormed', {
configurable: true,
value() {
return toWellFormedString(String(this));
},
});
}
if (typeof stringPrototype.isWellFormed !== 'function') {
Object.defineProperty(String.prototype, 'isWellFormed', {
configurable: true,
value() {
const value = String(this);
return toWellFormedString(value) === value;
},
});
}
if (!Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')) {
Object.defineProperty(ArrayBuffer.prototype, 'resizable', {
configurable: true,
get() {
return false;
},
});
}
if (typeof globalScope.SharedArrayBuffer === 'undefined') {
class SharedArrayBufferPolyfill extends ArrayBuffer {
get byteLength() {
return arrayBufferByteLength?.call(this) ?? 0;
}
get growable() {
return false;
}
}
Object.defineProperty(SharedArrayBufferPolyfill.prototype, Symbol.toStringTag, {
configurable: true,
value: 'SharedArrayBuffer',
});
globalScope.SharedArrayBuffer = SharedArrayBufferPolyfill;
}
+217
View File
@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import {
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useColors } from '../hooks/useColors';
type ScreenProps = {
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
route: RouteProp<RootStack, 'AddEvent'>;
};
/**
* Form screen for creating or editing an event.
*
* When navigated with `editEventId`, loads the existing event and populates
* the form fields. Validates that the event time is in the future before saving.
* Shows a transient success overlay for 1.5 s before popping back.
*/
export function AddEventScreen({ navigation, route }: ScreenProps) {
const colors = useColors();
const [title, setTitle] = useState('');
const [destination, setDestination] = useState('');
const [dateStr, setDateStr] = useState('');
const [timeStr, setTimeStr] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// If editing an existing event, populate the form
useEffect(() => {
if (!route.params?.editEventId) return;
(async () => {
const events = await loadEvents();
const event = events.find((e) => e.id === route.params?.editEventId);
if (event) {
setTitle(event.title);
setDestination(event.destination);
const d = new Date(event.eventTime);
setDateStr(d.toISOString().split('T')[0]);
setTimeStr(d.toTimeString().slice(0, 5));
}
})();
}, [route.params?.editEventId]);
const validate = (): boolean => {
if (!title.trim()) { setError('Title required'); return false; }
if (!destination.trim()) { setError('Destination required'); return false; }
if (!dateStr || !timeStr) { setError('Date and time required'); return false; }
const eventTime = new Date(`${dateStr}T${timeStr}`);
if (isNaN(eventTime.getTime())) { setError('Invalid date'); return false; }
if (eventTime <= new Date()) { setError('Date must be in the future'); return false; }
setError('');
return true;
};
const handleSave = async () => {
if (!validate()) return;
const eventTime = new Date(`${dateStr}T${timeStr}`);
if (route.params?.editEventId) {
// Update existing event
await updateEvent(route.params.editEventId, {
title: title.trim(),
destination: destination.trim(),
eventTime,
});
} else {
// Create new event
const event: CalendarEvent = {
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
title: title.trim(),
destination: destination.trim(),
eventTime,
source: 'manual',
};
await addEvent(event);
}
setSuccess(true);
setTimeout(() => {
navigation.goBack();
}, 1500);
};
return (
<View style={[styles.container, { backgroundColor: colors.background, position: 'relative' }]}>
<View style={styles.form}>
<Text style={[styles.label, { color: colors.text }]}>Title</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="e.g. Team Meeting"
placeholderTextColor={colors.subtext}
value={title}
onChangeText={setTitle}
autoCapitalize="words"
/>
<Text style={[styles.label, { color: colors.text }]}>Destination</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="e.g. Technikum Wien"
placeholderTextColor={colors.subtext}
value={destination}
onChangeText={setDestination}
autoCapitalize="words"
/>
<Text style={[styles.label, { color: colors.text }]}>Date</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="YYYY-MM-DD"
placeholderTextColor={colors.subtext}
value={dateStr}
onChangeText={setDateStr}
keyboardType="numbers-and-punctuation"
/>
<Text style={[styles.label, { color: colors.text }]}>Time</Text>
<TextInput
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
placeholder="HH:MM"
placeholderTextColor={colors.subtext}
value={timeStr}
onChangeText={setTimeStr}
keyboardType="numbers-and-punctuation"
/>
{error ? <Text style={[styles.errorText, { color: colors.error }]}>{error}</Text> : null}
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Update' : 'Save'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveBtn, styles.cancelBtn, { backgroundColor: colors.border }]}
onPress={() => navigation.goBack()}
>
<Text style={[styles.cancelText, { color: colors.text }]}>Cancel</Text>
</TouchableOpacity>
</View>
{success && (
<View style={[styles.successOverlay, { backgroundColor: colors.overlay }]}>
<View style={styles.successContent}>
<View style={styles.successCircle}>
<FontAwesomeIcon icon={faCheck} size={24} color="#fff" />
</View>
<Text style={[styles.successTitle, { color: colors.text }]}>Success!</Text>
<Text style={[styles.successSubtitle, { color: colors.subtext }]}>
{route.params?.editEventId ? 'Event updated' : 'Event added'}
</Text>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
form: { padding: 20 },
label: { fontSize: 14, fontWeight: '600', marginBottom: 6 },
input: {
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
marginBottom: 16,
borderWidth: 1,
},
errorText: { fontSize: 14, marginBottom: 8 },
saveBtn: {
backgroundColor: '#8B5CF6',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
cancelBtn: { marginTop: 12 },
cancelText: { fontSize: 16, fontWeight: '600' },
successOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 20,
paddingBottom: 40,
borderTopWidth: 1,
borderTopColor: '#34C759',
alignItems: 'center',
},
successContent: { alignItems: 'center', gap: 8 },
successCircle: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#34C759',
justifyContent: 'center',
alignItems: 'center',
},
successTitle: { fontSize: 18, fontWeight: '600' },
successSubtitle: { fontSize: 14 },
});

Some files were not shown because too many files have changed in this diff Show More