Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51565882ca | |||
| 229e0dd557 | |||
| 7da5acbba3 | |||
| 72f9400756 | |||
| 0493fcc939 | |||
| 44aaa32296 | |||
| bb286e1667 | |||
| 25676bf1e7 | |||
| 9c1f6ebf7e | |||
| b240d638ef | |||
| 2ac1777570 | |||
| 7018443b18 | |||
| 834025e560 | |||
| b3edf2c47b | |||
| 44fb492759 | |||
| ce1fa4972c | |||
| 9d6efdd43b | |||
| b3bba8cf38 | |||
| e52f2497e3 | |||
| 04e793cbb8 | |||
| 0501d66fdc | |||
| c221ef934a | |||
| 498958a27c | |||
| 09b5e7725d | |||
| bf252a9e9b | |||
| 6c7d57106c | |||
| d0e61ebdb0 | |||
| 9e461aab1a | |||
| de9a8606ab | |||
| 672d053ece | |||
| 316e5def72 | |||
| 3c6df95a86 | |||
| 98e74ee48d | |||
| 2eeae9a27b | |||
| 35971596b3 | |||
| 863996f06c | |||
| 1851d2ed47 | |||
| eac4f6e216 | |||
| 2c65dc1d5a | |||
| b87acfd0e1 | |||
| 08794eae05 | |||
| dc5b40ff6e | |||
| 4366f781d3 | |||
| ea88e9d34a | |||
| b541c809b2 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -19,5 +32,17 @@ WIENER_LINIEN_API_URL=https://api.wienerlinien.at/darwin-v2
|
|||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://timetoleave.app
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://timetoleave.app
|
||||||
|
|
||||||
# Deployment URL
|
# API rate limiting
|
||||||
DEPLOYMENT_URL=https://timetoleave.app
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -29,12 +30,24 @@ npm-debug.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.aider*
|
.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
|
# agent loop generated output
|
||||||
agent_loop/logs/
|
|
||||||
agent_loop/runs/
|
|
||||||
agent_loop/__pycache__/
|
|
||||||
logs/
|
logs/
|
||||||
runs/
|
runs/
|
||||||
|
|
||||||
# next.js build output (apps)
|
# next.js build output (apps)
|
||||||
apps/web/.next/
|
apps/web/.next/
|
||||||
|
|
||||||
|
apps/mobile/log.txt
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/TimeToLeave.iml" filepath="$PROJECT_DIR$/.idea/TimeToLeave.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Folder-specific settings
|
|
||||||
//
|
|
||||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
|
||||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
|
||||||
{}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"label": "Run Wiener Linien agent loop",
|
|
||||||
"command": "python",
|
|
||||||
"args": [
|
|
||||||
"ttl_agent_gemma4.py",
|
|
||||||
"--workspace", "/home/fegger/Code/TimeToLeave",
|
|
||||||
"--run-until-done"
|
|
||||||
],
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/agent_loop",
|
|
||||||
"use_new_terminal": true,
|
|
||||||
"allow_concurrent_runs": false,
|
|
||||||
"reveal": "always"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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 -->
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Aider Coding Rules
|
|
||||||
|
|
||||||
You are editing a real repository. Correctness is more important than speed.
|
|
||||||
|
|
||||||
## Operating Rules
|
|
||||||
|
|
||||||
1. Before editing, identify the exact requested task and restate the concrete files or behaviors that must
|
|
||||||
change.
|
|
||||||
2. Read the relevant existing files before making changes. Do not infer APIs, imports, types, or component
|
|
||||||
contracts from memory.
|
|
||||||
3. Implement every requested step. Do not skip checklist items, tests, wiring, exports, or documentation
|
|
||||||
updates that are part of the task.
|
|
||||||
4. Keep changes minimal and scoped. Do not refactor unrelated code, rename unrelated symbols, or change
|
|
||||||
behavior outside the task.
|
|
||||||
5. Prefer existing project patterns over new abstractions.
|
|
||||||
6. If a requirement is ambiguous, choose the smallest implementation that satisfies the written request and
|
|
||||||
state the assumption.
|
|
||||||
|
|
||||||
## Code Quality Rules
|
|
||||||
|
|
||||||
1. Do not introduce type errors, broken imports, missing exports, unused variables, or dead code.
|
|
||||||
2. Do not use placeholder code, TODOs, stubs, fake implementations, or comments claiming work is done when
|
|
||||||
it is not.
|
|
||||||
3. Preserve existing public APIs unless the task explicitly changes them.
|
|
||||||
4. Handle null, undefined, empty arrays, failed network calls, and invalid user input where relevant.
|
|
||||||
5. Keep async behavior explicit. Await promises that must complete before continuing.
|
|
||||||
6. Do not weaken or delete tests to make checks pass.
|
|
||||||
|
|
||||||
## Step Completion Rules
|
|
||||||
|
|
||||||
Before finishing, verify this checklist mentally and fix any failures:
|
|
||||||
|
|
||||||
- The requested behavior is fully implemented.
|
|
||||||
- Every required file is created or updated.
|
|
||||||
- All changed imports resolve.
|
|
||||||
- All changed types are valid.
|
|
||||||
- Existing behavior not mentioned in the task is preserved.
|
|
||||||
- Tests were added or updated when behavior changed.
|
|
||||||
- No generated files, build artifacts, cache files, or secrets were edited.
|
|
||||||
- The final response lists what changed and any checks that still need to be run.
|
|
||||||
|
|
||||||
## If You Are Unsure
|
|
||||||
|
|
||||||
Do not guess. Inspect the repository first. If still uncertain, make the smallest safe change and
|
|
||||||
explicitly mention the assumption in the final response.
|
|
||||||
@@ -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 |
|
||||||
@@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.0] — 2025-01-27
|
||||||
@@ -42,7 +77,7 @@ your calendar with real-time public transport data to tell you exactly when to l
|
|||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
- Bike route calculation from origin to departure station via OSRM
|
- Bike route calculation and final walking route calculation via OSRM
|
||||||
- Geocoding API integration for station lookups
|
- Geocoding API integration for station lookups
|
||||||
- Fallback and caching logic for API failures
|
- Fallback and caching logic for API failures
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
| # | Step | ✅ | ✔️ |
|
| # | Step | ✅ | ✔️ |
|
||||||
|---|---|----|-|
|
|---|---|----|-|
|
||||||
| 19 | Add local notifications (expo-notifications) | [x] | [x] |
|
| 19 | Add local notifications (expo-notifications) | [x] | [x] |
|
||||||
| 20 | Add native calendar import (post-MVP) | [~] | [~] |
|
| 20 | Add native calendar import with calendar selection | [x] | [x] |
|
||||||
| 21 | Add mobile tests (core + API + store + screens) | [x] | [x] |
|
| 21 | Add mobile tests (core + API + store + screens) | [x] | [x] |
|
||||||
| 22 | Prepare deployment (web backend + EAS mobile) | [x] | [x] |
|
| 22 | Prepare deployment (web backend + EAS mobile) | [x] | [x] |
|
||||||
| 23 | Release MVP (verify acceptance criteria) | [x] | [x] |
|
| 23 | Release MVP (verify acceptance criteria) | [x] | [x] |
|
||||||
|
|||||||
@@ -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] |
|
||||||
@@ -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:
|
||||||
@@ -1,79 +1,87 @@
|
|||||||
# TimeToLeave - Post-MVP Improvements Plan
|
# 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
|
## High Priority
|
||||||
|
|
||||||
### 1. Native Calendar Import
|
### 1. Offline-First Architecture
|
||||||
- Integrate with expo-calendar or react-native-calendar-events
|
- Cache journey and route data in `AsyncStorage` / `localStorage` for offline viewing.
|
||||||
- Request calendar permissions
|
- Background sync when connection is restored.
|
||||||
- Auto-sync events from Google/Apple calendars
|
- Add explicit "offline mode" UI indicators.
|
||||||
- Support multiple calendar sources
|
- Conflict resolution for concurrent event edits.
|
||||||
|
|
||||||
### 2. Push Notifications
|
### 2. Real Map Integration
|
||||||
- Implement FCM for Android and APNs for iOS
|
- Integrate `expo-maps` / `@vis.gl/react-google-maps` for visual route display.
|
||||||
- Server-side notification triggers for journey changes
|
- Show origin, destination, and train stations on a map.
|
||||||
- Real-time updates when train status changes
|
- Display bike route with turn-by-turn directions.
|
||||||
- Fallback to local notifications when offline
|
- Alternative route suggestions (e.g., faster vs. fewer changes).
|
||||||
|
|
||||||
### 3. Offline-First Architecture
|
### 3. Multiple Origins Support
|
||||||
- Use expo-sqlite for local caching
|
- Allow different origins per event (Home, Work, Custom presets).
|
||||||
- Cache journey data for offline access
|
- Quick origin switching in event detail and settings.
|
||||||
- Background sync when connection restored
|
- Store origin presets in persistent settings.
|
||||||
- Conflict resolution for concurrent edits
|
|
||||||
|
---
|
||||||
|
|
||||||
## Medium Priority
|
## Medium Priority
|
||||||
|
|
||||||
### 4. Real Map Integration
|
### 4. Server-Side Push Notifications
|
||||||
- Integrate expo-maps for visual route display
|
- Implement FCM for Android and APNs for iOS for real-time journey disruption alerts.
|
||||||
- Show train stations on map
|
- Real-time delay/cancellation push notifications.
|
||||||
- Display bike route with turn-by-turn directions
|
- Fallback to local notifications when the server is unreachable.
|
||||||
- Alternative route suggestions
|
|
||||||
|
|
||||||
### 5. Multiple Origins Support
|
### 5. Accessibility
|
||||||
- Allow different origins per event
|
- TalkBack / VoiceOver screen reader support on mobile.
|
||||||
- Home/Work/Custom origin presets
|
- Dynamic type scaling.
|
||||||
- Quick origin switching in event detail
|
- High contrast mode.
|
||||||
|
- WCAG 2.1 AA compliance audit on web.
|
||||||
|
|
||||||
### 6. Auto-Refresh
|
### 6. Analytics & Crash Reporting
|
||||||
- Refresh journey data when returning to app
|
- Integrate Sentry for error tracking on web and mobile.
|
||||||
- Background refresh for active events
|
- Opt-in usage analytics.
|
||||||
- Configurable refresh intervals
|
- Performance monitoring (Web Vitals, React Native startup time).
|
||||||
|
- In-app user feedback collection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Lower Priority
|
## Lower Priority
|
||||||
|
|
||||||
### 7. Accessibility
|
### 7. Advanced Features
|
||||||
- TalkBack/VoiceOver support
|
- Shared events with friends / family (collaborative departure planning).
|
||||||
- Dynamic type scaling
|
- Recurring event templates.
|
||||||
- High contrast mode
|
- Journey history and statistics dashboard.
|
||||||
- Screen reader optimizations
|
- Export / import event data (ICS, JSON).
|
||||||
|
|
||||||
### 8. Theming
|
---
|
||||||
- Dark mode support
|
|
||||||
- System theme following
|
|
||||||
- Custom color schemes
|
|
||||||
- Accessibility-compliant color contrasts
|
|
||||||
|
|
||||||
### 9. Analytics & Crash Reporting
|
## Technical Debt & Quality
|
||||||
- Sentry or similar for error tracking
|
|
||||||
- Usage analytics (opt-in)
|
|
||||||
- Performance monitoring
|
|
||||||
- User feedback collection
|
|
||||||
|
|
||||||
### 10. Advanced Features
|
### 8. Code Quality
|
||||||
- Shared events with friends/family
|
- Extract shared hooks to `packages/hooks` (deduplicate web and mobile hook implementations).
|
||||||
- Recurring event templates
|
- Increase unit test coverage across all packages.
|
||||||
- Journey history and statistics
|
- Add Playwright E2E tests for web critical flows.
|
||||||
- Export/import event data
|
- Add Detox E2E tests for mobile critical flows.
|
||||||
|
- Bundle size analysis and reduction.
|
||||||
|
|
||||||
## Technical Debt
|
### 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/`.
|
||||||
|
|
||||||
### 11. Code Quality
|
### 10. Documentation
|
||||||
- More comprehensive test coverage
|
- Keep `POST_MVP_PLAN.md` and `CHECKLIST.md` in sync with reality.
|
||||||
- E2E tests for critical flows
|
- Contributing guidelines (`CONTRIBUTING.md`).
|
||||||
- Performance optimization
|
- API versioning policy once the backend grows.
|
||||||
- Bundle size reduction
|
|
||||||
|
|
||||||
### 12. Documentation
|
|
||||||
- User documentation
|
|
||||||
- API documentation
|
|
||||||
- Contributing guidelines
|
|
||||||
- Architecture decisions (ADRs)
|
|
||||||
|
|||||||
@@ -1,27 +1,45 @@
|
|||||||
# Privacy Policy
|
# Privacy Policy
|
||||||
|
|
||||||
## Information We Collect
|
TimeToLeave is designed to keep user data local where possible. The app does not include third-party analytics or advertising trackers.
|
||||||
|
|
||||||
We do not collect any personal information or data from users. All data is stored locally on your device.
|
## Data Stored Locally
|
||||||
|
|
||||||
## Data Usage
|
- 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.
|
||||||
|
|
||||||
- **Location Data**: We use your device's location to find nearby stations and calculate travel times. This data is only used for the app's functionality and is not stored or transmitted.
|
## Data Sent to External Services
|
||||||
- **Calendar Data**: If you choose to import calendar events, we only read the events from your calendar and do not store or transmit them.
|
|
||||||
- **Notifications**: We use local notifications to remind you about events, which are stored locally on your device.
|
|
||||||
|
|
||||||
## Data Storage
|
Some features require network calls to calculate routes or import calendars:
|
||||||
|
|
||||||
All data is stored locally on your device and never leaves your device. We do not use any third-party analytics or tracking services.
|
| 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. |
|
||||||
|
|
||||||
## Third-Party Services
|
Remote ICS imports are restricted by server-side URL validation. Private and reserved hosts are blocked.
|
||||||
|
|
||||||
We do not use any third-party services that might collect or process your data. All processing happens locally on your device.
|
## Google Calendar
|
||||||
|
|
||||||
## Changes to This Privacy Policy
|
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.
|
||||||
|
|
||||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
|
## Location
|
||||||
|
|
||||||
## Contact Us
|
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.
|
||||||
|
|
||||||
If you have any questions about this Privacy Policy, please contact us at [contact email].
|
## 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.
|
||||||
|
|||||||
@@ -1,137 +1,128 @@
|
|||||||
# ⏱️ TimeToLeave
|
# TimeToLeave
|
||||||
|
|
||||||
> **TimeToLeave** is a smart departure planner that tells you exactly when to leave home to catch your public transport for upcoming appointments. It syncs with your personal calendar, checks real-time train/bus departures (HAFAS & WienerLinien), calculates your bike route to the station, and provides a live "Leave Status" based on real-time delays.
|
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.
|
||||||
|
|
||||||
   
|

|
||||||
|
|
||||||
## 🚀 How It Works
|
    
|
||||||
|
|
||||||
1. **Sync Your Calendar:** Import your `.ics` file or provide a calendar URL. The app extracts your upcoming events and destinations.
|
## What It Does
|
||||||
2. **Set Your Origin:** Define your home station or let the app use your current geolocation.
|
|
||||||
3. **Journey Calculation:** The app queries the HAFAS protocol and WienerLinien APIs to find the best public transport connections to your event destination.
|
|
||||||
4. **Real-Time Monitoring:** It monitors your train's real-time departure time, accounts for delays, and adds your local travel time (e.g., biking to the station) to calculate a dynamic countdown.
|
|
||||||
5. **Leave Status:** You get a clear status: `Leave now`, `On time`, `Delayed +X min`, or `Departure missed`.
|
|
||||||
|
|
||||||
## 🧱 Project Structure
|
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.
|
||||||
|
|
||||||
This project uses a monorepo setup (npm workspaces) to manage multiple, interconnected parts:
|
## Repository Layout
|
||||||
|
|
||||||
| Directory | Description |
|
| Path | Purpose |
|
||||||
| :--- | :--- |
|
| --- | --- |
|
||||||
| `apps/web/` | The main web dashboard built with **Next.js 16**, React 19, and Tailwind CSS 4. |
|
| `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/` | The on-the-go mobile client built with **React Native 0.81** and **Expo 54**. |
|
| `apps/mobile/` | Expo 54 / React Native 0.81 mobile app with native calendar, location, notification, and local storage integrations. |
|
||||||
| `packages/core/` | Shared domain logic, types (`Event`, `Journey`, `Station`), countdown utilities, and status calculators. |
|
| `packages/core/` | Shared types, defaults, HAFAS time parsing, journey parsing/scoring, countdown, formatting, and status utilities. |
|
||||||
| `packages/api-client/` | A lightweight client that handles API proxies for HAFAS requests, calendar parsing, geocoding, and bike routing. |
|
| `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. |
|
||||||
|
|
||||||
## 🛠 Development & Running the Application
|
## Prerequisites
|
||||||
|
|
||||||
### Prerequisites
|
- Node.js 20 or newer
|
||||||
|
- npm 9 or newer
|
||||||
|
- For mobile native builds: Expo/EAS prerequisites plus Android Studio or Xcode as needed
|
||||||
|
|
||||||
* Node.js (version 20.x or higher)
|
## Setup
|
||||||
* npm (version 9.x or higher)
|
|
||||||
|
|
||||||
### Installation
|
```bash
|
||||||
|
npm install
|
||||||
1. **Clone the repository:**
|
cp .env.example .env
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd TimeToLeave
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies:**
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Environment variables:**
|
|
||||||
* For `apps/web` and `apps/mobile`, copy `.env.example` to `.env` in each app directory and update the backend API URL and any required keys.
|
|
||||||
|
|
||||||
### Available Scripts
|
|
||||||
|
|
||||||
| Script | Command | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `dev` | `npm run dev` | Starts the Next.js development server for the Web dashboard. |
|
|
||||||
| `dev:mobile` | `npm run dev:mobile` | Starts the Expo development server for the Mobile client. |
|
|
||||||
| `build` | `npm run build` | Builds the production bundle for the Web application. |
|
|
||||||
| `test` | `npm run test` | Runs Vitest for the Web app and Jest for the Mobile app. |
|
|
||||||
| `lint` | `npm run lint` | Runs ESLint across both web and mobile clients. |
|
|
||||||
| `typecheck` | `npm run typecheck` | Runs TypeScript type checking across all workspaces. |
|
|
||||||
|
|
||||||
## 📝 Key Features & Tech Stack
|
|
||||||
|
|
||||||
### Web Application (`apps/web`)
|
|
||||||
* **Framework:** Next.js 16.2.6 (App Router)
|
|
||||||
* **UI:** React 19.2.4 with Tailwind CSS 4
|
|
||||||
* **State Management:** Zustand (for events and station selection)
|
|
||||||
* **Routing:** Next.js built-in routing for `/add-event`, `/calendar`, and `/event` views.
|
|
||||||
|
|
||||||
### Mobile Application (`apps/mobile`)
|
|
||||||
* **Framework:** React Native 0.81 via Expo 54
|
|
||||||
* **Navigation:** React Navigation 7 (Native Stack)
|
|
||||||
* **Device APIs:**
|
|
||||||
* `expo-location`: For geocoding your current position.
|
|
||||||
* `expo-calendar`: For native calendar event integration.
|
|
||||||
* `expo-notifications`: For native push notifications when it's time to leave.
|
|
||||||
* `@react-native-async-storage/async-storage`: For persisting settings and local state.
|
|
||||||
|
|
||||||
### Core Logic (`packages/core`)
|
|
||||||
* **Countdown Utilities:** Calculates time-deltas and assigns color codes (Red/Orange/Yellow/Green/Blue) based on urgency.
|
|
||||||
* **HAFAS Time Parsing:** Highly accurate timezone-aware parsing for HAFAS timestamps, specifically handling `Europe/Vienna` (CET/CEST) and DST transitions.
|
|
||||||
* **WienerLinien Support:** Native types and handling for Vienna public transport departures.
|
|
||||||
* **Leave Status:** Derives human-readable statuses (`Leave now`, `Delayed +10 min`, etc.) by comparing the best non-cancelled journey's real departure time against the current time.
|
|
||||||
|
|
||||||
## 📄 API Client Usage
|
|
||||||
|
|
||||||
The `@timetoleave/api-client` package provides a clean interface to interact with your backend proxy, which handles the heavy lifting of HAFAS protocol communication and calendar parsing.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ApiClient } from "@timetoleave/api-client";
|
|
||||||
|
|
||||||
// Initialize with your backend URL
|
|
||||||
const api = new ApiClient("http://localhost:3000");
|
|
||||||
|
|
||||||
// 1. Sync your calendar
|
|
||||||
const events = await api.fetchCalendar("https://example.com/calendar.ics", 7);
|
|
||||||
|
|
||||||
// 2. Search for a station by name
|
|
||||||
const stations = await api.searchStation("Wien Mitte");
|
|
||||||
|
|
||||||
// 3. Find journeys between stations for a specific date
|
|
||||||
const journeys = await api.searchJourneys(
|
|
||||||
stations[0].extId, // From
|
|
||||||
"dest:extId", // To
|
|
||||||
new Date() // Date
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Get a bike route from your current location to the station
|
|
||||||
const bikeRoute = await api.getBikeRoute(
|
|
||||||
48.2082, 16.3738, // From lat/lng
|
|
||||||
stations[0].lat, stations[0].lng // To lat/lng
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛡️ Testing & Quality Assurance
|
The web app reads environment variables from the workspace process. For deployment, configure the same values in the hosting environment.
|
||||||
|
|
||||||
The project provides comprehensive scripts for maintaining code quality:
|
Important variables:
|
||||||
|
|
||||||
* **Linting:** Use `npm run lint` to catch stylistic and structural errors via ESLint 9.
|
| Variable | Purpose |
|
||||||
* **Type Checking:** Use `npm run typecheck` to ensure strict type safety across the codebase via TypeScript 5.
|
| --- | --- |
|
||||||
* **Testing:**
|
| `HAFAS_URL` | ÖBB HAFAS endpoint. Defaults to `https://fahrplan.oebb.at/bin/mgate.exe`. |
|
||||||
* The web application uses **Vitest** (v4.1.5) with **jsdom** and **@testing-library/react**.
|
| `NOMINATIM_URL` and `NOMINATIM_USER_AGENT` | Geocoding endpoint and required user agent. |
|
||||||
* The mobile application uses **Jest** (v29.7.0) with **jest-expo** and **react-test-renderer**.
|
| `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. |
|
||||||
|
|
||||||
## 📂 File Structure
|
## Development
|
||||||
|
|
||||||
```text
|
| Command | Description |
|
||||||
├── apps/
|
| --- | --- |
|
||||||
│ ├── mobile/ # Mobile application using React Native and Expo
|
| `npm run dev` | Start the Next.js web app on `http://localhost:3000`. |
|
||||||
│ └── web/ # Web application using Next.js and Tailwind CSS
|
| `npm run dev:mobile` | Start the Expo development server. |
|
||||||
├── packages/
|
| `npm run build` | Build the web app. |
|
||||||
│ ├── api-client/ # API client for HAFAS, Calendar, and Routing proxies
|
| `npm run start` | Start the built web app. |
|
||||||
│ └── core/ # Shared domain types, countdowns, and HAFAS time utilities
|
| `npm run test` | Run web Vitest and mobile Jest suites. |
|
||||||
├── node_modules/ # Third-party dependencies
|
| `npm run lint` | Run ESLint across web, mobile, core, and api-client workspaces. |
|
||||||
└── README.md # The file you're reading now
|
| `npm run typecheck` | Run TypeScript checks across all workspaces. |
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Web App
|
||||||
*Built for developers who bike to the train and hate missing their connections.*
|
|
||||||
|
Current user-facing routes:
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
The add/edit event UI is a modal component, not a standalone page route.
|
||||||
|
|
||||||
|
## 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/`.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
from agent_base import main
|
|
||||||
|
|
||||||
WRITER_PROMPT = """You are a disciplined TypeScript software engineering agent implementing the Wiener Linien feature on the TimeToLeave project.
|
|
||||||
|
|
||||||
## Checklist Tracking
|
|
||||||
|
|
||||||
`CHECKLIST.md` uses three checkbox states:
|
|
||||||
- `[ ]` — pending and required; blocks the next step
|
|
||||||
- `[x]` — done
|
|
||||||
- `[~]` — optional or deferred; never blocks advancement
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The ✅ column is yours; the ✔️ column belongs to the review agent.
|
|
||||||
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
|
|
||||||
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
|
|
||||||
- Implement only that one step. Do not implement any later steps.
|
|
||||||
- After completing the step, mark its ✅ box by changing `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
|
|
||||||
- Do not mark the ✔️ column — that belongs to the review agent.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
Project root: files are specified as paths relative to the project root (e.g. `src/types/index.ts`).
|
|
||||||
Stack: Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest.
|
|
||||||
Feature: Wiener Linien real-time departures via the free OGD Echtzeitdaten REST API (`https://www.wienerlinien.at/ogd_realtime`).
|
|
||||||
Architecture pattern: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → UI components in `src/app`.
|
|
||||||
|
|
||||||
## Work Step By Step
|
|
||||||
|
|
||||||
- Start by reading `CHECKLIST.md` to identify the current step, then read the relevant existing files.
|
|
||||||
- State what the current step requires before making changes.
|
|
||||||
- Implement one coherent change at a time. Prefer small targeted edits over rewrites.
|
|
||||||
- Review the diff mentally before submitting — ensure it matches the intended behavior.
|
|
||||||
- Do not move to the next step. The review agent must mark ✔️ before the next step begins.
|
|
||||||
|
|
||||||
## Quality Bar
|
|
||||||
|
|
||||||
- Patch the existing project; do not restart from scratch unless the file does not yet exist.
|
|
||||||
- Use relative file paths only.
|
|
||||||
- Return full file contents — no partial diffs or placeholders.
|
|
||||||
- Keep TypeScript strictness intact. Never use `any`; use `unknown` at JSON/API boundaries with explicit type guards.
|
|
||||||
- Prefer `const` over `let`; never use `var`.
|
|
||||||
- Use async/await throughout; never mix Promise chains and callbacks.
|
|
||||||
- Use `import type` for type-only imports.
|
|
||||||
- Use named exports; avoid default exports in library code.
|
|
||||||
- Keep server-only code out of client components. API calls to Wiener Linien must go through proxy routes, not directly from the browser.
|
|
||||||
- Preserve existing working behavior unless the current step explicitly changes it.
|
|
||||||
- For UI work: semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
|
||||||
- If you cannot produce a valid response matching the schema, emit: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
|
|
||||||
- Return only JSON matching the writer schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
REVIEWER_PROMPT = """<|think|>
|
|
||||||
You are a strict senior TypeScript reviewer embedded in a code-generation loop for the TimeToLeave project.
|
|
||||||
|
|
||||||
## Checklist Tracking
|
|
||||||
|
|
||||||
`CHECKLIST.md` uses three checkbox states:
|
|
||||||
- `[ ]` — pending and required; blocks the next step
|
|
||||||
- `[x]` — done
|
|
||||||
- `[~]` — optional or deferred; never blocks advancement
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
|
|
||||||
- Only review steps whose ✅ box is already `[x]`. Do not attempt to review unimplemented steps.
|
|
||||||
- Before reviewing, confirm that all preceding steps have both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking.
|
|
||||||
- If the step passes the quality bar below, set verdict to "approve". The orchestrator will mark ✔️ automatically.
|
|
||||||
- If issues remain, set verdict to "needs_changes". Report the failures with file paths and line numbers.
|
|
||||||
|
|
||||||
## Review Scope
|
|
||||||
|
|
||||||
- Review one step at a time in the order steps appear in `CHECKLIST.md`.
|
|
||||||
- Cross-reference the implementation against the step description in `CHECKLIST.md`.
|
|
||||||
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
|
|
||||||
|
|
||||||
## What to Check
|
|
||||||
|
|
||||||
**Correctness**
|
|
||||||
- Behavior matches the intent described in the CHECKLIST step.
|
|
||||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
|
||||||
- No regressions in previously working behavior.
|
|
||||||
|
|
||||||
**Tests**
|
|
||||||
- Tests exist for new code covering the main success path, edge cases, and failure behavior.
|
|
||||||
- Tests are not weakened or removed just to make the suite pass.
|
|
||||||
- External services (Wiener Linien API, geolocation, time) are mocked; tests do not depend on live network availability.
|
|
||||||
|
|
||||||
**Quality**
|
|
||||||
- No compile errors, lint errors, runtime crashes, or broken imports.
|
|
||||||
- TypeScript strictness is intact — no `any` used as a shortcut.
|
|
||||||
- Server-only code is not imported into client components.
|
|
||||||
- Wiener Linien API calls go through proxy routes, not directly from the browser.
|
|
||||||
- Error handling is explicit; user-facing failures are understandable.
|
|
||||||
- No generated artifacts, caches, logs, or local environment files are committed.
|
|
||||||
- Dependencies unchanged unless necessary and justified.
|
|
||||||
|
|
||||||
**Scope**
|
|
||||||
- The change is scoped to the current CHECKLIST step — no unrelated modifications.
|
|
||||||
|
|
||||||
**Accessibility (UI steps only)**
|
|
||||||
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Confirm that these checks would pass before setting verdict to "approve":
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
npm run build
|
|
||||||
npm run typecheck
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
If any check would fail, set verdict to "needs_changes", report the failure with exact details, and leave the step for the writing agent to fix.
|
|
||||||
|
|
||||||
## Review priorities
|
|
||||||
|
|
||||||
1. build-breaking defects
|
|
||||||
2. test-breaking defects
|
|
||||||
3. runtime-breaking defects
|
|
||||||
4. mismatch with the CHECKLIST step intent
|
|
||||||
5. missing files, exports, wiring, or integration
|
|
||||||
6. unsafe behavior
|
|
||||||
7. incorrect types, null handling, async handling, state management
|
|
||||||
8. missing edge-case handling
|
|
||||||
9. important but non-blocking maintainability issues
|
|
||||||
|
|
||||||
Output field guidance:
|
|
||||||
- critical_issues: only issues that break build, tests, or runtime
|
|
||||||
- important_improvements: significant but not immediately blocking issues
|
|
||||||
- preserve: list anything in the draft that is correct and must not be changed
|
|
||||||
- rewrite_strategy: concrete alternative approaches the writer should try for unfixed critical issues
|
|
||||||
- missing_files: files required by the CHECKLIST step that are absent
|
|
||||||
- test_gaps: risky behavior with materially missing test coverage
|
|
||||||
- file_comments: specific, actionable guidance tied to a file path
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Be concrete and rewrite-oriented.
|
|
||||||
- Prefer issues the writer can directly fix in the next pass.
|
|
||||||
- Do not ask questions.
|
|
||||||
- Do not praise unless identifying something that must be preserved.
|
|
||||||
- Assume the writer should patch the current code, not restart from scratch.
|
|
||||||
- If the draft appears unchanged from a previous attempt for a given issue, escalate that issue to critical and suggest an alternative implementation approach.
|
|
||||||
- If you have already flagged an issue and it was not fixed, say specifically what is still wrong and why the previous attempt failed.
|
|
||||||
- Return only JSON matching the review schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DESIGN_PROMPT = """<|think|>
|
|
||||||
You are a disciplined TypeScript architect working inside a coding loop on the TimeToLeave project.
|
|
||||||
|
|
||||||
Your job is to produce a concise implementation design for the current CHECKLIST step before coding begins.
|
|
||||||
|
|
||||||
Project context:
|
|
||||||
- Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest
|
|
||||||
- Architecture: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → `src/app` components
|
|
||||||
- Feature: Wiener Linien real-time departures via `https://www.wienerlinien.at/ogd_realtime`
|
|
||||||
- Read `CHECKLIST.md` to determine which step is being designed
|
|
||||||
|
|
||||||
Design priorities:
|
|
||||||
1. file layout — which files to create or modify, and why
|
|
||||||
2. responsibilities — what each file owns
|
|
||||||
3. interfaces — types and function signatures
|
|
||||||
4. integration points — how this step connects to existing code
|
|
||||||
5. dependencies — imports from existing modules
|
|
||||||
6. testing plan — what to test and how to mock
|
|
||||||
7. risks — anything that could break existing behavior
|
|
||||||
8. assumptions — things taken as given
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Optimize for patching an existing codebase; prefer minimal file churn.
|
|
||||||
- Identify any conflicts with existing code (naming, module structure, API contracts).
|
|
||||||
- Flag required structural changes separately from new additions.
|
|
||||||
- If the step can be completed by modifying a single existing file, say so explicitly rather than proposing new files.
|
|
||||||
- Do not redesign the whole project unless the step requires it.
|
|
||||||
- Keep the design concrete and implementation-ready.
|
|
||||||
- Return only JSON matching the design schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main("TS", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Default workspace to the project root (parent of this script's agent_loop/ dir)
|
|
||||||
# so the script works without --workspace when run from anywhere.
|
|
||||||
os.environ.setdefault("AGENT_WORKSPACE", str(Path(__file__).resolve().parent.parent))
|
|
||||||
os.environ.setdefault("AGENT_TASK", "Implement the next pending step in CHECKLIST.md")
|
|
||||||
os.environ.setdefault("OLLAMA_API_BASE", "http://100.103.83.12:11435")
|
|
||||||
os.environ.setdefault("WRITER_MODEL", "qwen3.6:27b-64k")
|
|
||||||
os.environ.setdefault("REVIEWER_MODEL", "qwen3.6:27b-64k")
|
|
||||||
os.environ.setdefault("DESIGN_MODEL", "qwen3.6:27b-64k")
|
|
||||||
os.environ.setdefault("AGENT_MAX_REVIEW_LOOPS", "12")
|
|
||||||
|
|
||||||
from agent_base_gemma4 import main
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TimeToLeave — project-specific agent
|
|
||||||
#
|
|
||||||
# Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 · Vitest
|
|
||||||
# Run: python ttl_agent_gemma4.py --task "..." --workspace <project-root> --write-to-workspace
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
WRITER_PROMPT = """You are a software engineering agent implementing features on the TimeToLeave project.
|
|
||||||
|
|
||||||
## FILE EXTENSION RULE — CHECK EVERY FILE BEFORE SUBMITTING
|
|
||||||
|
|
||||||
- `.tsx` — any file that contains JSX (`<Tag />`, `<div>`, `return (...)` with markup)
|
|
||||||
- `.ts` — everything else: hooks, clients, routes, types, utilities
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
src/app/event/WienerLinienSection.tsx ← renders JSX → .tsx
|
|
||||||
src/hooks/useWienerLinien.ts ← no JSX → .ts
|
|
||||||
src/lib/wienerlinien-client.ts ← no JSX → .ts
|
|
||||||
src/app/api/wienerlinien/stops/route.ts← no JSX → .ts
|
|
||||||
|
|
||||||
Wrong extension = broken build. Verify each path ends in the correct suffix.
|
|
||||||
|
|
||||||
## Checklist Tracking
|
|
||||||
|
|
||||||
`CHECKLIST.md` uses three checkbox states:
|
|
||||||
- `[ ]` — pending and required; blocks the next step
|
|
||||||
- `[x]` — done
|
|
||||||
- `[~]` — optional or deferred; never blocks advancement
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The ✅ column is yours; the ✔️ column belongs to the review agent.
|
|
||||||
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
|
|
||||||
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
|
|
||||||
- Implement only that one step. Do not implement any later steps.
|
|
||||||
- After completing the step, change its ✅ from `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
|
|
||||||
- Do not mark the ✔️ column — that belongs to the review agent.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
|
||||||
|
|
||||||
**CRITICAL:** This Next.js version has breaking changes. Before using any Next.js API (routing,
|
|
||||||
metadata, image, font, caching), read the relevant guide in `node_modules/next/dist/docs/`.
|
|
||||||
Heed all deprecation notices. APIs and file conventions may differ from your training data.
|
|
||||||
|
|
||||||
## Architecture — Four Layers
|
|
||||||
|
|
||||||
Always follow this pattern:
|
|
||||||
|
|
||||||
src/lib/<name>-client.ts ← singleton, calls external API, server-only
|
|
||||||
src/app/api/<name>/route.ts ← proxy: validates input, calls client, returns NextResponse
|
|
||||||
src/hooks/use<Name>.ts ← hook: calls proxy route, manages loading/error/data
|
|
||||||
src/app/**/<Name>Section.tsx ← component: receives props or calls hook, renders UI
|
|
||||||
|
|
||||||
Reference implementations (read before writing):
|
|
||||||
- Client: src/lib/bike-routing-client.ts
|
|
||||||
- Route: src/app/api/bike-route/route.ts
|
|
||||||
- Hook: src/hooks/useBikeRoute.ts
|
|
||||||
- Component: src/app/event/BikeSection.tsx
|
|
||||||
- ApiClient: src/lib/api-service.ts — use ApiClient for caching + retries in new clients
|
|
||||||
- Constants: src/lib/constants.ts — add env vars here as `process.env.X ?? "default"`
|
|
||||||
- Types: src/types/index.ts — add all new types here
|
|
||||||
|
|
||||||
## Next.js Rules
|
|
||||||
|
|
||||||
- Route handlers: `export async function GET(request: NextRequest)` or `POST`. Named exports only.
|
|
||||||
- Imports: `NextRequest`, `NextResponse` from `"next/server"`.
|
|
||||||
- `"use client"` goes at the top of a file only when it uses `useState`, `useEffect`, event
|
|
||||||
handlers, `window`, or `navigator`. Server components and route handlers must never have it.
|
|
||||||
- Never import `src/lib` clients, `crypto`, or `process.env` secrets into client components.
|
|
||||||
- Path alias: use `@/` for `src/` (e.g. `import { Foo } from "@/types"`).
|
|
||||||
|
|
||||||
## Error Handling in Routes
|
|
||||||
|
|
||||||
Every route 500 must follow this exact pattern:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
// ...
|
|
||||||
} catch (error) {
|
|
||||||
const corrId = randomUUID().slice(0, 8);
|
|
||||||
console.error(`[${corrId}] <description>:`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Human-readable message", correlationId: corrId },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Input validation errors return `{ error: "..." }` with status 400 — no correlationId needed.
|
|
||||||
|
|
||||||
## TypeScript Rules
|
|
||||||
|
|
||||||
- Never use `any`. Use `unknown` at JSON/API boundaries with explicit type guards.
|
|
||||||
- All new types go in `src/types/index.ts`.
|
|
||||||
- Use `import type` for type-only imports.
|
|
||||||
- Named exports everywhere. No default exports in `src/lib` or `src/hooks`.
|
|
||||||
- `const` over `let`. Never `var`. Async/await throughout — no mixed Promise chains.
|
|
||||||
|
|
||||||
## Tailwind CSS v4
|
|
||||||
|
|
||||||
- Utility classes directly in JSX only. Never `@apply` in CSS files.
|
|
||||||
- Dark mode uses the `dark:` variant (toggled via a class on `<html>`).
|
|
||||||
- Do not modify `tailwind.config.ts` unless strictly necessary.
|
|
||||||
|
|
||||||
## Vitest Rules
|
|
||||||
|
|
||||||
- Test files: `src/lib/__tests__/`, `src/app/api/__tests__/`, `src/hooks/__tests__/`, `src/app/**/__tests__/`.
|
|
||||||
- Use `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*` APIs.
|
|
||||||
- Mock `global.fetch` or use `vi.mock` to intercept HTTP — no live network in tests.
|
|
||||||
- Mock all external services: any third-party API, geolocation, timers (`vi.useFakeTimers()`).
|
|
||||||
- Cover: main success path, input validation, error/failure behavior.
|
|
||||||
- Import the module under test, not internal helpers directly.
|
|
||||||
|
|
||||||
## General Rules
|
|
||||||
|
|
||||||
- Patch the existing project. Only create new files when the layer does not exist yet.
|
|
||||||
- Return full file contents — no partial diffs, ellipsis, or placeholders.
|
|
||||||
- Use relative file paths only.
|
|
||||||
- Use `npm` (project uses `package-lock.json`).
|
|
||||||
- Do not add features, abstractions, or cleanup beyond what the current CHECKLIST step requires.
|
|
||||||
- Preserve existing working behavior unless the step explicitly changes it.
|
|
||||||
- Do not commit generated files, caches, logs, or `.env` secrets.
|
|
||||||
- Before finalising the files array, verify every path: does it contain JSX? → `.tsx`. No JSX? → `.ts`.
|
|
||||||
- If you cannot produce a valid response: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
|
|
||||||
- Return only JSON matching the writer schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
REVIEWER_PROMPT = """You are a strict senior code reviewer embedded in a code-generation loop for the TimeToLeave project.
|
|
||||||
|
|
||||||
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
|
||||||
|
|
||||||
## Checklist Tracking
|
|
||||||
|
|
||||||
`CHECKLIST.md` uses three checkbox states:
|
|
||||||
- `[ ]` — pending and required; blocks the next step
|
|
||||||
- `[x]` — done
|
|
||||||
- `[~]` — optional or deferred; never blocks advancement
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
|
|
||||||
- Only review steps whose ✅ box is already `[x]`. Do not review unimplemented steps.
|
|
||||||
- Confirm all preceding steps have both ✅ and ✔️ as `[x]` before reviewing. If not, report what is blocking.
|
|
||||||
- If the step passes all checks below, set verdict to "approve". The orchestrator marks ✔️ automatically.
|
|
||||||
- If issues remain, set verdict to "needs_changes" and report failures with file paths and line numbers.
|
|
||||||
|
|
||||||
## Review Scope
|
|
||||||
|
|
||||||
One CHECKLIST step at a time. Cross-reference against the step description. Report concrete issues
|
|
||||||
with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
|
|
||||||
|
|
||||||
## What to Check
|
|
||||||
|
|
||||||
**Correctness**
|
|
||||||
- Behavior matches the current CHECKLIST step's intent.
|
|
||||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
|
||||||
- No regressions in previously working behavior.
|
|
||||||
|
|
||||||
**File extensions**
|
|
||||||
- `.tsx` for files containing JSX; `.ts` for everything else (routes, hooks, lib, types).
|
|
||||||
- Flag any component returning JSX saved as `.ts`, or any non-JSX file saved as `.tsx`.
|
|
||||||
|
|
||||||
**Next.js conventions**
|
|
||||||
- Route handlers export named `GET`/`POST` functions with `(request: NextRequest)` signature.
|
|
||||||
- `"use client"` is present when a component uses `useState`, `useEffect`, event handlers, `window`,
|
|
||||||
or `navigator`; absent on all other files.
|
|
||||||
- No server-only imports (`src/lib` clients, `crypto`, `process.env` secrets) in client components.
|
|
||||||
- Next.js APIs match what is documented in `node_modules/next/dist/docs/` — flag anything that looks
|
|
||||||
like a training-data artifact from an older Next.js version.
|
|
||||||
- Path alias `@/` used for `src/` imports.
|
|
||||||
|
|
||||||
**Error handling**
|
|
||||||
- All route 500 errors return `{ error: string, correlationId: string }` using `randomUUID().slice(0, 8)`.
|
|
||||||
- Input validation errors return `{ error: string }` with status 400.
|
|
||||||
|
|
||||||
**Tests**
|
|
||||||
- Tests exist for the new code covering the main success path, input validation, and failure behavior.
|
|
||||||
- Only Vitest APIs: `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*`.
|
|
||||||
- All external services and network calls are mocked — no live network in tests.
|
|
||||||
- Tests are not weakened or removed just to make the suite pass.
|
|
||||||
|
|
||||||
**TypeScript**
|
|
||||||
- No `any`. `unknown` at API/JSON boundaries with explicit type guards.
|
|
||||||
- No missing null/undefined checks on values from API responses or array indexing.
|
|
||||||
- No missing `await`, unhandled rejections, or mixed async styles.
|
|
||||||
- No missing exports for symbols referenced by other files.
|
|
||||||
- `import type` used for type-only imports.
|
|
||||||
|
|
||||||
**Scope and quality**
|
|
||||||
- Change is scoped to the current CHECKLIST step only.
|
|
||||||
- No generated artifacts, caches, logs, or `.env` secrets committed.
|
|
||||||
- Dependencies unchanged unless necessary and justified.
|
|
||||||
|
|
||||||
**Accessibility (UI steps only)**
|
|
||||||
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
These must pass before setting verdict to "approve":
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
npm run build
|
|
||||||
npm run typecheck
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
If any check would fail, set verdict to "needs_changes" and report the exact failure details.
|
|
||||||
|
|
||||||
## Review Priorities
|
|
||||||
|
|
||||||
1. Build-breaking defects
|
|
||||||
2. Test-breaking defects
|
|
||||||
3. Runtime-breaking defects
|
|
||||||
4. Mismatch with CHECKLIST step intent
|
|
||||||
5. Missing files, exports, wiring, or integration
|
|
||||||
6. Incorrect Next.js APIs or conventions
|
|
||||||
7. Unsafe behavior, missing null checks, async errors
|
|
||||||
8. Missing edge-case handling
|
|
||||||
9. Important but non-blocking maintainability issues
|
|
||||||
|
|
||||||
## Output Field Guidance
|
|
||||||
|
|
||||||
- critical_issues: issues that break build, tests, or runtime
|
|
||||||
- important_improvements: significant but not immediately blocking
|
|
||||||
- preserve: anything correct that must not be changed
|
|
||||||
- rewrite_strategy: concrete alternative approaches for unfixed critical issues
|
|
||||||
- missing_files: files required by the step that are absent
|
|
||||||
- test_gaps: risky behavior with materially missing test coverage
|
|
||||||
- file_comments: specific, actionable guidance tied to a file path
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Be concrete and rewrite-oriented. Prefer issues the writer can fix in the next pass.
|
|
||||||
- Do not ask questions. Do not praise unless identifying something that must be preserved.
|
|
||||||
- Assume the writer should patch the current code, not restart from scratch.
|
|
||||||
- If the draft is unchanged from a previous attempt on a flagged issue, escalate to critical and
|
|
||||||
suggest a concrete alternative implementation approach.
|
|
||||||
- If you have already flagged an issue that was not fixed, say specifically what is still wrong
|
|
||||||
and why the previous attempt failed.
|
|
||||||
- Return only JSON matching the review schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DESIGN_PROMPT = """You are a disciplined architect working inside a coding loop on the TimeToLeave project.
|
|
||||||
|
|
||||||
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
|
||||||
|
|
||||||
Produce a concise implementation design for the current CHECKLIST step before coding begins.
|
|
||||||
|
|
||||||
Before designing:
|
|
||||||
1. Read `CHECKLIST.md` to identify the current step.
|
|
||||||
2. Read `node_modules/next/dist/docs/` for any Next.js API the step will use — this version differs from training data.
|
|
||||||
3. Check whether `ApiClient` in `src/lib/api-service.ts` covers the new external service's caching and retry needs before proposing a new client.
|
|
||||||
4. Check `src/lib/constants.ts` for the env-var pattern before adding new configuration.
|
|
||||||
|
|
||||||
Architecture layers (follow the existing pattern):
|
|
||||||
- `src/lib/<name>-client.ts` — singleton, server-only, wraps external API via ApiClient
|
|
||||||
- `src/app/api/<name>/route.ts` — proxy route, validates input, calls client, NextResponse
|
|
||||||
- `src/hooks/use<Name>.ts` — hook, fetches from proxy, manages loading/error/data
|
|
||||||
- `src/app/**/<Name>Section.tsx` — component, renders UI, `"use client"` where needed
|
|
||||||
|
|
||||||
Design priorities:
|
|
||||||
1. File layout — which files to create or modify (prefer modifying over creating new files)
|
|
||||||
2. Responsibilities — what each file owns
|
|
||||||
3. Interfaces — exported types and function signatures
|
|
||||||
4. Integration points — how this connects to existing code
|
|
||||||
5. Dependencies — exact imports from existing modules
|
|
||||||
6. Testing plan — what to test, which services to mock, which Vitest APIs to use
|
|
||||||
7. Risks — anything that could break existing behavior or violate Next.js conventions
|
|
||||||
8. Assumptions — things taken as given
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Optimize for patching the existing codebase. Prefer minimal file churn.
|
|
||||||
- Identify conflicts with existing code (naming, module structure, API contracts).
|
|
||||||
- Flag required structural changes separately from new additions.
|
|
||||||
- If the step can be completed by modifying a single existing file, say so explicitly.
|
|
||||||
- Do not redesign unrelated parts of the project.
|
|
||||||
- Keep the design concrete and immediately usable by the writer.
|
|
||||||
- Return only JSON matching the design schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main("TTL", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
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>
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
extends: ['expo'],
|
|
||||||
rules: {
|
|
||||||
'react-native/no-inline-styles': 'off',
|
|
||||||
},
|
|
||||||
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
|
|
||||||
};
|
|
||||||
@@ -40,3 +40,8 @@ yarn-error.*
|
|||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
.idea/
|
||||||
|
.zed/
|
||||||
|
agent_loop
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from './src/services/expoNotifications';
|
||||||
import AppNavigator from './src/navigation/AppNavigator';
|
import AppNavigator from './src/navigation/AppNavigator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useEffect(() => {
|
const initRef = useRef(false);
|
||||||
// Request notification permissions on app start
|
|
||||||
Notifications.requestPermissionsAsync();
|
|
||||||
|
|
||||||
// Set up notification handler
|
useEffect(() => {
|
||||||
Notifications.setNotificationHandler({
|
// Run initialization only once
|
||||||
handleNotification: async () => ({
|
if (initRef.current) return;
|
||||||
shouldShowAlert: true,
|
initRef.current = true;
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
(async () => {
|
||||||
shouldShowBanner: true,
|
// Request notification permissions
|
||||||
shouldShowList: true,
|
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 />;
|
return <AppNavigator />;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "dark",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#007AFF"
|
"backgroundColor": "#090816"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#007AFF"
|
"backgroundColor": "#090816"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.ACCESS_FINE_LOCATION",
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
"android.permission.INTERNET"
|
"android.permission.INTERNET",
|
||||||
|
"android.permission.ACCESS_COARSE_LOCATION"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
@@ -41,6 +42,12 @@
|
|||||||
"expo-location",
|
"expo-location",
|
||||||
"expo-notifications"
|
"expo-notifications"
|
||||||
],
|
],
|
||||||
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy"
|
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy",
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "floegger"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 871 KiB |
@@ -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 |
|
After Width: | Height: | Size: 871 KiB |
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './src/polyfills/sharedArrayBuffer';
|
||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|||||||
@@ -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/.*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
preset: 'jest-expo',
|
|
||||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
@@ -1,41 +1,50 @@
|
|||||||
{
|
{
|
||||||
"name": "@timetoleave/mobile",
|
"name": "@timetoleave/mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||||
"lint": "echo 'no lint yet'",
|
"lint": "eslint src/",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
"@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": "^7.2.4",
|
||||||
"@react-navigation/native-stack": "^7.14.14",
|
"@react-navigation/native-stack": "^7.14.14",
|
||||||
"@timetoleave/api-client": "*",
|
"@timetoleave/api-client": "*",
|
||||||
"@timetoleave/core": "*",
|
"@timetoleave/core": "*",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.34",
|
||||||
"expo-calendar": "^55.0.14",
|
"expo-calendar": "~15.0.8",
|
||||||
"expo-location": "^55.1.9",
|
"expo-dev-client": "~6.0.21",
|
||||||
"expo-notifications": "^55.0.22",
|
"expo-location": "~19.0.8",
|
||||||
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.2.4",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "^5.7.0",
|
"react-native-maps": "^1.20.0",
|
||||||
"react-native-screens": "^4.24.0"
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "^15.15.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
"@testing-library/react-native": "^13.3.3",
|
"@testing-library/react-native": "^13.3.3",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/react": "^19",
|
"@types/react": "~19.1.10",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expo": "~54.0.0",
|
"jest-expo": "~54.0.0",
|
||||||
"react-test-renderer": "19.2.4",
|
"react-test-renderer": "19.1.0",
|
||||||
"ts-jest": "^29.4.9",
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "^8.59.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,7 +91,8 @@ describe('calendar service', () => {
|
|||||||
|
|
||||||
const result = await fetchNativeEvents(startDate, endDate);
|
const result = await fetchNativeEvents(startDate, endDate);
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
// Only the event with a location is returned; events without a location are filtered out
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toEqual({
|
expect(result[0]).toEqual({
|
||||||
id: 'evt1',
|
id: 'evt1',
|
||||||
title: 'Team Meeting',
|
title: 'Team Meeting',
|
||||||
@@ -99,13 +100,6 @@ describe('calendar service', () => {
|
|||||||
eventTime: new Date('2025-01-15T10:00:00'),
|
eventTime: new Date('2025-01-15T10:00:00'),
|
||||||
source: 'native:cal1',
|
source: 'native:cal1',
|
||||||
});
|
});
|
||||||
expect(result[1]).toEqual({
|
|
||||||
id: 'evt2',
|
|
||||||
title: 'Dentist',
|
|
||||||
destination: '',
|
|
||||||
eventTime: new Date('2025-01-20T14:00:00'),
|
|
||||||
source: 'native:cal2',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
|
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
|
||||||
['cal1', 'cal2'],
|
['cal1', 'cal2'],
|
||||||
@@ -114,7 +108,33 @@ describe('calendar service', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles events with missing title or startDate', async () => {
|
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.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||||
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
|
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
|
||||||
@@ -123,7 +143,7 @@ describe('calendar service', () => {
|
|||||||
id: 'evt1',
|
id: 'evt1',
|
||||||
calendarId: 'cal1',
|
calendarId: 'cal1',
|
||||||
title: null as unknown as string,
|
title: null as unknown as string,
|
||||||
location: null,
|
location: 'Wien Hbf',
|
||||||
startDate: null as unknown as string | Date,
|
startDate: null as unknown as string | Date,
|
||||||
},
|
},
|
||||||
] as Calendar.Event[]);
|
] as Calendar.Event[]);
|
||||||
@@ -132,7 +152,7 @@ describe('calendar service', () => {
|
|||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Untitled Event');
|
expect(result[0].title).toBe('Untitled Event');
|
||||||
expect(result[0].destination).toBe('');
|
expect(result[0].destination).toBe('Wien Hbf');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
// Tests for core utilities
|
// Tests for core utilities
|
||||||
import { calculateCountdown } from '@timetoleave/core';
|
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('core utilities', () => {
|
||||||
describe('calculateCountdown', () => {
|
describe('calculateCountdown', () => {
|
||||||
@@ -77,4 +94,50 @@ describe('core utilities', () => {
|
|||||||
expect(result.color).toBe('green');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
rescheduleAllNotifications
|
rescheduleAllNotifications
|
||||||
} from '../store/eventStore';
|
} from '../store/eventStore';
|
||||||
import { calculateLeaveByTime } from '../services/notifications';
|
import { calculateLeaveByTime } from '../services/notifications';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from '../services/expoNotifications';
|
||||||
|
|
||||||
// Mock AsyncStorage
|
// Mock AsyncStorage
|
||||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
@@ -21,8 +21,8 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
|
|||||||
removeItem: jest.fn(),
|
removeItem: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock expo-notifications
|
// Mock notification adapter
|
||||||
jest.mock('expo-notifications', () => ({
|
jest.mock('../services/expoNotifications', () => ({
|
||||||
getAllScheduledNotificationsAsync: jest.fn(),
|
getAllScheduledNotificationsAsync: jest.fn(),
|
||||||
cancelScheduledNotificationAsync: jest.fn(),
|
cancelScheduledNotificationAsync: jest.fn(),
|
||||||
cancelAllScheduledNotificationsAsync: jest.fn(),
|
cancelAllScheduledNotificationsAsync: jest.fn(),
|
||||||
@@ -137,11 +137,16 @@ describe('eventStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('origin station', () => {
|
describe('origin station', () => {
|
||||||
it('should load null when no origin exists', async () => {
|
it('should load the default origin when no saved origin exists', async () => {
|
||||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
const station = await loadOriginStation();
|
const station = await loadOriginStation();
|
||||||
expect(station).toBeNull();
|
expect(station).toEqual({
|
||||||
|
name: 'Goethegasse 36, 2340 Moedling',
|
||||||
|
extId: '1231701',
|
||||||
|
lat: 48.0806926,
|
||||||
|
lng: 16.2908052,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load origin station from AsyncStorage', async () => {
|
it('should load origin station from AsyncStorage', async () => {
|
||||||
@@ -183,6 +188,9 @@ describe('eventStore', () => {
|
|||||||
expect(settings).toEqual({
|
expect(settings).toEqual({
|
||||||
bufferMinutes: 30,
|
bufferMinutes: 30,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
arrivalBufferMinutes: 5,
|
||||||
|
showWalkingOption: true,
|
||||||
|
showBikeOption: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,6 +210,9 @@ describe('eventStore', () => {
|
|||||||
const settings = {
|
const settings = {
|
||||||
bufferMinutes: 45,
|
bufferMinutes: 45,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
arrivalBufferMinutes: 5,
|
||||||
|
showWalkingOption: true,
|
||||||
|
showBikeOption: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveNotificationSettings(settings);
|
await saveNotificationSettings(settings);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Tests for notification service
|
// Tests for notification service
|
||||||
// Mock expo-notifications before importing
|
// Mock notification adapter before importing
|
||||||
jest.mock('expo-notifications', () => ({
|
jest.mock('../services/expoNotifications', () => ({
|
||||||
setNotificationHandler: jest.fn(),
|
setNotificationHandler: jest.fn(),
|
||||||
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||||
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
||||||
@@ -23,7 +23,7 @@ import type { Event, Journey } from '@timetoleave/core';
|
|||||||
|
|
||||||
describe('notifications service', () => {
|
describe('notifications service', () => {
|
||||||
describe('calculateLeaveByTime', () => {
|
describe('calculateLeaveByTime', () => {
|
||||||
it('should calculate leave-by time from event time minus buffer', () => {
|
it('should calculate leave-by time from event time minus arrival buffer minus reminder buffer', () => {
|
||||||
const event: Event = {
|
const event: Event = {
|
||||||
id: 'test-1',
|
id: 'test-1',
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
@@ -32,10 +32,12 @@ describe('notifications service', () => {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, [], 30);
|
const leaveByTime = calculateLeaveByTime(event, [], 30, 5);
|
||||||
|
|
||||||
// Leave-by time should be 30 minutes before event time
|
// Leave-by time should be 30 minutes before event time
|
||||||
const expectedTime = new Date('2025-01-01T09:30:00Z');
|
// (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());
|
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,10 +77,11 @@ describe('notifications service', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
|
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||||
|
|
||||||
// Should use earliest non-cancelled journey (journey-2 at 07:00) minus buffer
|
// Should use earliest non-cancelled journey (journey-2 at 07:00)
|
||||||
const expectedTime = new Date('2025-01-01T06:30:00Z');
|
// 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());
|
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,10 +121,11 @@ describe('notifications service', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
|
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||||
|
|
||||||
// Should use journey-2 since journey-1 is cancelled
|
// Should use journey-2 since journey-1 is cancelled
|
||||||
const expectedTime = new Date('2025-01-01T06:30:00Z');
|
// 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());
|
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,10 +153,11 @@ describe('notifications service', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30);
|
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||||
|
|
||||||
// All journeys cancelled, fall back to event time minus buffer
|
// All journeys cancelled, fall back to event time minus arrival buffer minus reminder buffer
|
||||||
const expectedTime = new Date('2025-01-01T09:30:00Z');
|
// = 10:00 - 30 min - 5 min = 09:25
|
||||||
|
const expectedTime = new Date('2025-01-01T09:25:00Z');
|
||||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,9 +170,12 @@ describe('notifications service', () => {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveByTime = calculateLeaveByTime(event, [], 0);
|
const leaveByTime = calculateLeaveByTime(event, [], 30, 0);
|
||||||
|
|
||||||
expect(leaveByTime.getTime()).toBe(event.eventTime.getTime());
|
// 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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { EventListScreen } from '../screens/EventListScreen';
|
import { EventListScreen } from '../screens/EventListScreen';
|
||||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||||
import { loadEvents } from '../store/eventStore';
|
import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore';
|
||||||
import { calculateCountdown } from '@timetoleave/core';
|
import { calculateCountdown } from '@timetoleave/core';
|
||||||
|
|
||||||
// Mock the store and utilities
|
// Mock the store and utilities
|
||||||
jest.mock('../store/eventStore', () => ({
|
jest.mock('../store/eventStore', () => ({
|
||||||
loadEvents: jest.fn(),
|
loadEvents: jest.fn(),
|
||||||
|
loadOriginStation: jest.fn(),
|
||||||
|
loadNotificationSettings: jest.fn(),
|
||||||
removeEvent: jest.fn(),
|
removeEvent: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -18,12 +20,86 @@ jest.mock('@timetoleave/core', () => ({
|
|||||||
calculateCountdown: jest.fn(),
|
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
|
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
|
||||||
jest.mock('@react-navigation/native', () => ({
|
jest.mock('@react-navigation/native', () => ({
|
||||||
...jest.requireActual('@react-navigation/native'),
|
...jest.requireActual('@react-navigation/native'),
|
||||||
useFocusEffect: (callback: () => void) => {
|
useFocusEffect: (callback: () => void) => {
|
||||||
// Execute the callback immediately so the component loads data
|
const React = jest.requireActual('react');
|
||||||
callback();
|
React.useEffect(() => {
|
||||||
|
callback();
|
||||||
|
}, [callback]);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -67,6 +143,19 @@ const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as un
|
|||||||
describe('EventListScreen', () => {
|
describe('EventListScreen', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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 () => {
|
it('should render empty state when no events', async () => {
|
||||||
@@ -77,7 +166,7 @@ describe('EventListScreen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getByText('Keine Termine')).toBeTruthy();
|
expect(getByText('No upcoming events')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +176,7 @@ describe('EventListScreen', () => {
|
|||||||
id: 'test-1',
|
id: 'test-1',
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
destination: 'Test Destination',
|
destination: 'Test Destination',
|
||||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -115,7 +204,7 @@ describe('EventListScreen', () => {
|
|||||||
id: 'test-1',
|
id: 'test-1',
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
destination: 'Test Destination',
|
destination: 'Test Destination',
|
||||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -147,12 +236,12 @@ describe('AddEventScreen', () => {
|
|||||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy();
|
expect(getByPlaceholderText('e.g. Team Meeting')).toBeTruthy();
|
||||||
expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy();
|
expect(getByPlaceholderText('e.g. Technikum Wien')).toBeTruthy();
|
||||||
expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy();
|
expect(getByPlaceholderText('YYYY-MM-DD')).toBeTruthy();
|
||||||
expect(getByPlaceholderText('SS:MM')).toBeTruthy();
|
expect(getByPlaceholderText('HH:MM')).toBeTruthy();
|
||||||
expect(getByText('Speichern')).toBeTruthy();
|
expect(getByText('Save')).toBeTruthy();
|
||||||
expect(getByText('Abbrechen')).toBeTruthy();
|
expect(getByText('Cancel')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show validation errors', () => {
|
it('should show validation errors', () => {
|
||||||
@@ -161,11 +250,11 @@ describe('AddEventScreen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Try to save without filling form
|
// Try to save without filling form
|
||||||
const saveButton = getByText('Speichern');
|
const saveButton = getByText('Save');
|
||||||
fireEvent.press(saveButton);
|
fireEvent.press(saveButton);
|
||||||
|
|
||||||
// Should show error text
|
// Should show error text
|
||||||
expect(getByText('Titel erforderlich')).toBeTruthy();
|
expect(getByText('Title required')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate date format', () => {
|
it('should validate date format', () => {
|
||||||
@@ -174,15 +263,15 @@ describe('AddEventScreen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fill in all required fields except date format is invalid
|
// Fill in all required fields except date format is invalid
|
||||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
|
||||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
|
||||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date');
|
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), 'invalid-date');
|
||||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
|
||||||
|
|
||||||
const saveButton = getByText('Speichern');
|
const saveButton = getByText('Save');
|
||||||
fireEvent.press(saveButton);
|
fireEvent.press(saveButton);
|
||||||
|
|
||||||
expect(getByText('Ungültiges Datum')).toBeTruthy();
|
expect(getByText('Invalid date')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate future date', () => {
|
it('should validate future date', () => {
|
||||||
@@ -191,14 +280,14 @@ describe('AddEventScreen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fill in all required fields with a past date
|
// Fill in all required fields with a past date
|
||||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
|
||||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
|
||||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01');
|
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), '2020-01-01');
|
||||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
|
||||||
|
|
||||||
const saveButton = getByText('Speichern');
|
const saveButton = getByText('Save');
|
||||||
fireEvent.press(saveButton);
|
fireEvent.press(saveButton);
|
||||||
|
|
||||||
expect(getByText('Datum muss in der Zukunft liegen')).toBeTruthy();
|
expect(getByText('Date must be in the future')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||