Compare commits

...

23 Commits
main ... be

Author SHA1 Message Date
dancingCycle e2d19d0ce6 feat(db): adjust db/sql/trip-updates.sql 2023-12-16 13:42:25 +01:00
dancingCycle 5eb046c5e6 feat(be): refactor 2023-12-16 13:32:06 +01:00
dancingCycle 2e1bb7b166 feat(be): refactor 2023-12-06 14:01:32 +01:00
dancingCycle 142086e3d0 feat(be): insert diff between GTFSR and database 2023-12-06 13:55:32 +01:00
dancingCycle c5c23f1161 feat(db): refactor db/bin/trip-updates.sh 2023-12-06 13:39:02 +01:00
dancingCycle 7001733350 feat(be): compare database with GTFS Realtime feed 2023-12-06 13:15:43 +01:00
dancingCycle 19595e10e3 feat(be): query db 2023-12-05 23:14:10 +01:00
dancingCycle 7b5ea82f65 feat(be): keep fetching GTFS Realtime using setTimeout 2023-12-05 22:16:39 +01:00
dancingCycle 4b4131902a feat(be): add be 2023-11-30 15:45:11 +01:00
dancingCycle 7cbb31bdd3 feat(db): adjust db/bin/trip_updates.sh 2023-11-30 14:56:12 +01:00
dancingCycle f0552030c5 feat(db): add db/bin/trip_updates.sh 2023-11-30 14:45:41 +01:00
dancingCycle 25b139ddd7 chore: adjust package.json to "pbf": "3.2.1" 2023-11-30 14:18:57 +01:00
dancingCycle d13f28d79e chore: rm renovate config 2023-11-30 14:15:17 +01:00
dancingCycle 134dcbcd8b chore: bump version to v0.4.0 2023-07-27 14:29:12 +02:00
dancingCycle 72815f7cbd adjust page Contact 2023-07-27 14:15:40 +02:00
dancingCycle 9ff992b39a add page TripUpdates and VehiclePositions 2023-07-27 12:34:42 +02:00
dancingCycle c5cd44e632 feat: apply function readPbf(buffer) 2023-07-26 14:36:12 +02:00
dancingCycle d67e967c7a feat: add function readPbf(buffer) 2023-07-25 19:17:16 +02:00
dancingCycle 5ed587f0ce chore: adjust url for API call 2023-07-25 18:19:45 +02:00
dancingCycle ac91c2b741 chore: adjust url for API call 2023-07-25 18:17:42 +02:00
dancingCycle 6d94dc0732 chore: adjust url for API call 2023-07-25 18:15:01 +02:00
dancingCycle 82b92eac86 feat: clear Home page 2023-07-25 18:04:06 +02:00
dancingCycle b5e4f567a3 feat: adjust home page 2023-02-15 15:38:11 +01:00
33 changed files with 2359 additions and 5140 deletions

View File

@ -5,10 +5,10 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import Contact from './pages/contact';
import Home from './pages/home';
import Table from './pages/table-page';
import Map from './pages/map-page';
import NavBar from './components/nav-bar';
import Table from './pages/table-page';
import TripUpdates from './pages/trip-updates';
export default function App() {
return (
@ -20,9 +20,9 @@ export default function App() {
<NavBar />
<Routes>
<Route path="/" element={<Map />} />
<Route path="/table" element={<Table />} />
<Route path="/map" element={<Map />} />
<Route path="/contact" element={<Contact />} />
<Route path="/map" element={<Map />} />
<Route path="/trip-updates" element={<TripUpdates />} />
</Routes>
</BrowserRouter>
);

32
app/components/input.js Normal file
View File

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import Form from 'react-bootstrap/Form';
/*controlled component: input form value controlled by React*/
const InputSearch = ({id, name, onChange, placeholder, title, type, value}) => {
return (
<>
<Form.Control
aria-label={title}
className={name}
id={id}
name={name}
onChange={onChange}
placeholder={placeholder}
title={title}
type={type}
value={value}
/>
</>
);
};
export default InputSearch;
InputSearch.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
name: PropTypes.string,
placeholder: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
onChange: PropTypes.func
};

View File

@ -5,18 +5,17 @@ import { LinkContainer } from 'react-router-bootstrap';
function NavigationBar () {
return (
<Navbar collapseOnSelect fixed="top" bg="dark" expand="xxl" variant="dark">
//TODO make brand available through configuration
<Navbar.Brand href="/">GTFS Realtime Display</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<LinkContainer to="/table">
<Nav.Link>Table</Nav.Link>
<LinkContainer to="/trip-updates">
<Nav.Link>TripUpdates</Nav.Link>
</LinkContainer>
</Nav>
<Nav className="mr-auto">
<LinkContainer to="/map">
<Nav.Link>Map</Nav.Link>
<Nav.Link>VehiclePositions</Nav.Link>
</LinkContainer>
</Nav>
<Nav className="mr-auto">

36
app/components/select.js Normal file
View File

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import Form from 'react-bootstrap/Form';
/*controlled component: select controlled by React*/
const Select = ({defaultValue, id, name, onChange, options, title}) => {
if (options) {
return (
<Form.Select
aria-label="select table entries per page"
className={name}
defaultValue={defaultValue}
name={name}
id={id}
onChange={onChange}
title={title}
>
{options.map((item, index) => (
<option key={index} value={item}>
{item}
</option>
))}
</Form.Select>
);
} else {
return <p>Select options unavailable.</p>;
}
};
export default Select;
Select.propTypes = {
id: PropTypes.string,
name: PropTypes.string,
defaultValue: PropTypes.number,
onChange: PropTypes.func,
options: PropTypes.array,
title: PropTypes.string
};

View File

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import Button from 'react-bootstrap/Button';
import Stack from 'react-bootstrap/Stack';
import PropTypes from 'prop-types';
import Select from './select';
import {selectOptions} from '../utils/select-options';
import TripUpdatesTable from './trip-updates-table';
import Input from './input';
import config from '../config';
export default function TripUpdatesPage(){
/*store and initialise data in function component state*/
const [oset, setOset] = useState(1);
const [limit, setLimit] = useState(parseInt(selectOptions[0],10));
const [searchField, setSearchField] = useState('');
const handleClickPrev = () => {
setOset((oset) => (oset > 1 ? --oset : oset));
};
const handleClickNext = () => {
setOset((oset) => ++oset);
};
const handleChangeLimit = (event) => {
setLimit((limit) => parseInt(event.target.value,10));
};
const handleSearch = (e) => {
setSearchField((searchField)=>e.target.value);
};
return (
<>
<Stack direction="horizontal" gap={1} className="m-1">
<Button variant="secondary" onClick={handleClickPrev}>
prev
</Button>
<Button variant="secondary" onClick={handleClickNext}>
next
</Button>
<Select
defaultValue={selectOptions[0]}
id="tablePageLimit"
name="tablePageLimit"
onChange={handleChangeLimit}
options={selectOptions}
/>
<Input
id="tablePageSearch"
name="tablePageSearch"
onChange={handleSearch}
placeholder="Search table globally"
type="search"
title="Enter search value"
value={searchField}
/>
</Stack>
<TripUpdatesTable
isFetched={false}
oset={oset}
limit={limit}
filter={searchField}
/>
</>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import TripUpdatesEntry from './trip-updates-table-entry';
export default function TripUpdatesTableEntries ({aryData}) {
if (aryData.length > 0) {
//iterate over array
return aryData.map((item, index) => {
if (item.trip) {
//console.log('TripUpdatesTableEntries: trip available');
const trip = item.trip;
return (
<TripUpdatesEntry
tripId={typeof trip.trip_id !== 'undefined' ? trip.trip_id : null}
routeId={typeof trip.route_id !== 'undefined' ? trip.route_id : null}
directionId={typeof trip.direction_id !== 'undefined' ? trip.direction_id : null}
startTime={typeof trip.start_time !== 'undefined' ? trip.start_time : null}
startDate={typeof trip.start_date !== 'undefined' ? trip.start_date : null}
timestamp={typeof item.timestamp !== 'undefined' ? item.timestamp : null}
delay={typeof item.delay !== 'undefined' ? item.delay : null}
key={index}
/>
);
} else {
console.log('ERROR: TripUpdatesTableEntries: REQUIRED trip NOT available');
}
});
}else{
//data is empty
return null;
}
}
TripUpdatesTableEntries.propTypes = {
aryData: PropTypes.array
};

View File

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
/*destructure props object*/
const TripUpdatesTableEntry = ({
tripId,
routeId,
directionId,
startTime,
startDate,
timestamp,
delay
}) => {
return (
<tr>
<td>{tripId}</td>
<td>{routeId}</td>
<td>{directionId}</td>
<td>{startTime}</td>
<td>{startDate}</td>
<td>{timestamp}</td>
<td>{delay}</td>
</tr>
);
};
TripUpdatesTableEntry.propTypes = {
tripId: PropTypes.string,
routeId: PropTypes.string,
directionId: PropTypes.number,
startTime: PropTypes.string,
startDate: PropTypes.string,
timestamp: PropTypes.number,
delay: PropTypes.number
};
export default TripUpdatesTableEntry;

View File

@ -0,0 +1,17 @@
import React from 'react';
const TripUpdatesTableHead = () => {
return (
<tr>
<th>Trip:trip_id</th>
<th>Trip:route_id</th>
<th>Trip:direction_id</th>
<th>Trip:start_time</th>
<th>Trip:start_date</th>
<th>timestamp</th>
<th>delay</th>
</tr>
);
};
export default TripUpdatesTableHead;

View File

@ -0,0 +1,124 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import PropTypes from 'prop-types';
import Table from 'react-bootstrap/Table';
import Alert from 'react-bootstrap/Alert';
import Badge from 'react-bootstrap/Badge';
import {readPbf} from '../utils/gtfs-rt-utils';
import TripUpdatesTableHead from './trip-updates-table-head';
import TripUpdatesTableEntries from './trip-updates-table-entries';
import config from '../config';
import {filterData} from '../utils/filter-data';
/*the simplest way to define a component is to write a JavaScript function*/
/*destructure props object*/
export default function TripUpdatesTable ({isFetched, oset, limit, filter}) {
////console.log('TripUpdatesTable: oset: ' + oset);
//////console.log('TripUpdatesTable: limit: ' + limit);
const [ary, setAry] = useState([]);
const [aryFiltered, setAryFiltered] = useState([]);
const [fetchCompleted, setFetchCompleted] = useState(isFetched);
/*get arry in a JavaScript function*/
const getRry = async () => {
try {
/*TODO handle errors: https://www.valentinog.com/blog/await-react/*/
const address = `${config.API}gtfs-rt`;
const res = await axios.get(address,
{
responseType: 'arraybuffer'
});
if(res.data){
////console.log('getRry() res available');
const rry = readPbf(res.data);
const rryLngth= rry.length;
////console.log('getRry() rryLngh: ' + rryLngth);
const rryETU = new Array();
let countEntityTripUpdate = 0;
rry.forEach(entity => {
const entityTripUpdate = entity.trip_update;
if (entityTripUpdate) {
rryETU.push(entityTripUpdate);
countEntityTripUpdate++;
}
});
////console.log('getRry() countEntityTripUpdate: ' + countEntityTripUpdate);
const rryOstLmt = new Array();
let j = 0
for(let i = (oset - 1) * limit; i < rryLngth ; i++){
if(j < limit){
rryOstLmt.push(rryETU[i]);
j++;
}
}
const rryOstLmtLngth = rryOstLmt.length;
////console.log('getRry() rryOstLmtLngh: ' + rryOstLmtLngth);
setAry((ary) => rryOstLmt);
//TODO let data=filterData(res.data,'tdb_stops',filter);
//TODO setAryFiltered((aryFiltered) => data);
setAryFiltered((aryFiltered) => rryOstLmt);
}else{
console.error('getRry() res NOT available');
}
} catch (err) {
console.error('err.message: ' + err.message);
setAry((ary) => []);
setAryFiltered((aryFiltered) => []);
}
};
useEffect(()=>{
setAryFiltered((aryFiltered)=>{
let filtered=filterData(ary,'tdb_stops',filter);
return filtered;
});
},[filter]);
useEffect(()=>{
setFetchCompleted((fetchCompleted)=>true);
}, [ary]);
useEffect(() => {
/*effect goes here*/
getRry();
/*use an empty dependency array to ensure the hook is running only once*/
/*TODO study dependency array: https://reactjs.org/docs/hooks-effect.html*/
}, [oset,limit]);
if(fetchCompleted && aryFiltered.length > 0){
/*return a React element*/
return (
<>
<Table
striped
bordered
hover
size="sm"
variant="dark"
responsive
>
<thead>
<TripUpdatesTableHead />
</thead>
<tbody>
<TripUpdatesTableEntries aryData={aryFiltered} />
</tbody>
</Table>
</>
);
}else{
return (
<Alert variant={'secondary'} onClose={() => setShow(false)} dismissible>
<Badge bg="secondary">TripUpdate</Badge> entities loading...
</Alert>
);
}
}
TripUpdatesTable.propTypes = {
isFetched: PropTypes.bool,
offset: PropTypes.number,
limit: PropTypes.number,
filter: PropTypes.string
};

3
app/config.js Normal file
View File

@ -0,0 +1,3 @@
export default {
API: 'https://www.v1gtfs-rt-p.api.swingbe.de/'
};

View File

@ -4,32 +4,36 @@ const VERSION = packageInfo.version;
const Contact = () => {
return (
<>
<h2>About this website</h2>
<p>
For questions about this website please do not hesitate to reach out to dialog (at) swingbe (dot) de.
For questions about this website please do not hesitate to reach out to dialog
(at) swingbe (dot) de.
</p>
<p>
Source code has been made public on{' '}
Source code is controlled and provided using{' '}
<a
href="https://github.com/Software-Ingenieur-Begerad/gtfs-rt-display"
href="https://git.wtf-eg.de/dancesWithCycles/gtfs-rt-display"
target="_blank"
>
GitHub
</a>.
Git
</a>
.
</p>
<h2>Imprint</h2>
<address>
<strong>Software Ingenieur Begerad</strong>
<br />
Lammer Heide 87
<br />
38116 Braunschweig
<br />
Deutschland
<br />
</address>
<h2>Other</h2>
<p>
<a
href="https://www.swingbe.de/imprint"
target="_blank"
>
Imprint
</a>
</p>
<p>
<a
href="https://www.swingbe.de/privacy-policy"
target="_blank"
>
Privacy Policy
</a>
</p>
<p>
Version: {VERSION}
</p>

View File

@ -1,8 +1,10 @@
import React from 'react';
import React, {useState} from 'react';
import axios from 'axios';
export default function Home() {
return (
<>
<h1>Home</h1>
<p>Home</p>
</>
);
}

View File

@ -1,40 +1,53 @@
import React, {useEffect,useState} from 'react';
import axios from 'axios';
import Alert from 'react-bootstrap/Alert';
import Badge from 'react-bootstrap/Badge';
import Map from '../components/map/map';
import parseMessages from '../utils/gtfs-rt-utils';
import {parseMessages, readPbf} from '../utils/gtfs-rt-utils';
import config from '../config';
export default function MapPage() {
/*storage*/
const [vehPos, setVehPos] = useState([]);
const getData= async ()=>{
//console.log('getData() start...');
////console.log('getData() start...');
try {
/*TODO handle errors: https://www.valentinog.com/blog/await-react/*/
//TODO Make fields available via configuration!
let url = 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions';
const res = await axios.get(url,
const address = `${config.API}gtfs-rt`;
const res = await axios.get(address,
{
responseType: 'arraybuffer'
//responseType: 'blob'
});
if(res.data){
//TODO remove debugging
//console.log('getData() res available');
/*parse messages*/
const messages = parseMessages(res.data);
//console.log('getData() messages.length: '+messages.length);
//console.log('getRry() res available');
const rry = readPbf(res.data);
const rryLngth= rry.length;
//console.log('getRry() rryLngh: ' + rryLngth);
const rryEVP = new Array();
let countEntityVehiclePositions = 0;
rry.forEach(entity => {
const entityVehiclePosition = entity.vehicle;
if (entityVehiclePosition) {
rryEVP.push(entityVehiclePosition);
countEntityVehiclePositions++;
}
});
//console.log('getRry() countEntityVehiclePositions: ' + countEntityVehiclePositions);
/*set state*/
setVehPos(messages);
setVehPos((messages) => rryEVP);
}else{
console.error('getData() res NOT available');
console.error('getRry() res NOT available');
}
} catch (err) {
console.error('err.message: ' + err.message);
}
//console.log('getData() done.');
////console.log('getData() done.');
};
useEffect(()=>{
@ -51,9 +64,19 @@ export default function MapPage() {
clearInterval(intervalCall);
};
},[]);
if (vehPos.length < 0) {
return (
<>
<Map messages={vehPos}/>
</>
);
} else {
return (
<Alert variant={'secondary'} onClose={() => setShow(false)} dismissible>
<Badge bg="secondary">VehiclePostion</Badge> entities loading...
</Alert>
);
}
}

View File

@ -1,8 +1,9 @@
import React, {useEffect,useState} from 'react';
import axios from 'axios';
import parseMessages from '../utils/gtfs-rt-utils';
import {parseMessages, readPbf} from '../utils/gtfs-rt-utils';
import Table from '../components/table/table';
export default function TablePage() {
const [vehPos, setVehPos] = useState([]);
const getData= async ()=>{
@ -10,13 +11,18 @@ export default function TablePage() {
try {
/*TODO handle errors: https://www.valentinog.com/blog/await-react/*/
//TODO Make fields available via configuration!
let url = 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions';
//let url = 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions';
//let url = 'http://localhost:65533/gtfs-rt';
let url = 'https://www.v1gtfs-rt-p.api.swingbe.de/gtfs-rt'
const res = await axios.get(url,
{
responseType: 'arraybuffer'
});
if(res.data){
//console.log('getData() res available');
/*parse messages*/
readPbf(res.data);
const messages = parseMessages(res.data);
//console.log('getData() messages.length: '+messages.length);
setVehPos(messages);

View File

@ -0,0 +1,9 @@
import React from 'react';
import TripUpdatesPage from '../components/trip-updates-page';
export default function TripUpdates() {
return (
<>
<TripUpdatesPage />
</>
);
};

205
app/utils/filter-data.js Normal file
View File

@ -0,0 +1,205 @@
function filterData(data, name,filter){
if(data.length>0){
//console.log('filterData() data.length: '+data.length);
//console.log('filterData() name: '+name);
//console.log('filterData() filter:'+filter);
switch(name){
case 'fare_zones_history':
//TODO implement
console.log('filterData() //TODO implement fare_zones_history');
return data;
break;
case 'tdb_fare_zones':
return data.filter((item, index) => {
return (
item.id.toLowerCase().includes(filter.toLowerCase()) ||
(item.external!==null && item.external.toLowerCase().includes(filter.toLowerCase())) ||
(item.internal!==null && item.internal.toLowerCase().includes(filter.toLowerCase())) ||
(item.name!==null && item.name.toLowerCase().includes(filter.toLowerCase())) ||
(item.short_name!==null && item.short_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.type!==null && item.type.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_from!==null && item.valid_from.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_until!==null && item.valid_until.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'lct_msg':
return data.filter((item, index) => {
return (
item.bs_id.toLowerCase().includes(filter.toLowerCase()) ||
(item.vc_trip!==null && item.vc_trip.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_route!==null && item.vc_route.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_tenant!==null && item.vc_tenant.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_date!==null && item.vc_date.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_time!==null && item.vc_time.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_lon!==null && item.vc_lon.toLowerCase().includes(filter.toLowerCase())) ||
(item.vc_lat!==null && item.vc_lat.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'localization':
return data.filter((item, index) => {
return (
item.localization_id.toLowerCase().includes(filter.toLowerCase()) ||
(item.name!==null && item.name.toLowerCase().includes(filter.toLowerCase())) ||
(item.lang_de!==null && item.lang_de.toLowerCase().includes(filter.toLowerCase())) ||
(item.lang_en!==null && item.lang_en.toLowerCase().includes(filter.toLowerCase())) ||
(item.version_id!==null && item.version_id.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'relations':
return data.filter((item, index) => {
return (
(item.dtype!==null && item.dtype.toLowerCase().includes(filter.toLowerCase())) ||
(item.id!==null && item.id.toLowerCase().includes(filter.toLowerCase())) ||
(item.active!=null && item.active.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.direct_purchase!==null && item.direct_purchase.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.disabled!==null && item.disabled.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.info!==null && item.info.toLowerCase().includes(filter.toLowerCase())) ||
(item.last_modified!==null && item.last_modified.toLowerCase().includes(filter.toLowerCase())) ||
(item.price_level!==null && item.price_level.toLowerCase().includes(filter.toLowerCase())) ||
(item.start_zone!==null && item.start_zone.toLowerCase().includes(filter.toLowerCase())) ||
(item.target_zone!==null && item.target_zone.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_from!==null && item.valid_from.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_until!==null && item.valid_until.toLowerCase().includes(filter.toLowerCase())) ||
(item.via_name!==null && item.via_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.via_fare_zone!==null && item.via_fare_zone.toLowerCase().includes(filter.toLowerCase())) ||
(item.zones!==null && item.zones.toLowerCase().includes(filter.toLowerCase())) ||
(item.matching_via_id!==null && item.matching_via_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.variant!==null && item.variant.toLowerCase().includes(filter.toLowerCase())) ||
(item.created_user_id!==null && item.created_user_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.last_modified_user_id!==null && item.last_modified_user_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.comment!== null && item.comment.toLowerCase().includes(filter.toLowerCase())) ||
(item.reverse_direction_id!== null && item.reverse_direction_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.via_id!==null && item.via_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.all_transit_zones!=null && item.all_transit_zones.toString().toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'price_levels':
return data.filter((item, index) => {
return (
item.price_level_id.toString().toLowerCase().includes(filter.toLowerCase()) ||
(item.short_name!==null && item.short_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.name!==null && item.name.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'price':
return data.filter((item, index) => {
return (
item.price_id.toString().toLowerCase().includes(filter.toLowerCase()) ||
(item.product_id!==null && item.product_id.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.price_level_id!==null && item.price_level_id.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.id_ticket!==null && item.id_ticket.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.price!==null && item.price.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.duration!==null && item.duration.toLowerCase().includes(filter.toLowerCase())) ||
(item.priority!==null && item.priority.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4add_info!==null && item.localization_id4add_info.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4ticket_descr!==null && item.localization_id4ticket_descr.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4sale_text1!==null && item.localization_id4sale_text1.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4sale_text2!==null && item.localization_id4sale_text2.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4ticket_note1!==null && item.localization_id4ticket_note1.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4ticket_note2!==null && item.localization_id4ticket_note2.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.localization_id4note_lang!==null && item.localization_id4note_lang.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.filter_code!==null && item.filter_code.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.version_id!==null && item.version_id.toString().toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'product':
return data.filter((item, index) => {
return (
item.product_id.toString().toLowerCase().includes(filter.toLowerCase()) ||
(item.id_prod!==null && item.id_prod.toLowerCase().includes(filter.toLowerCase())) ||
(item.ext_prod_localization_id!==null && item.ext_prod_localization_id.toLowerCase().includes(filter.toLowerCase())) ||
(item.info_prod_localization_id!==null && item.info_prod_localization_id.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'sales_parameter':
return data.filter((item, index) => {
return (
item.sales_parameter_id.toString().includes(filter.toLowerCase()) ||
(item.product_id!==null && item.product_id.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.cnt_presale_days!==null && item.cnt_presale_days.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.app_prsnt_after_val!==null && item.app_prsnt_after_val.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.bday!==null && item.bday.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.num_add_names!==null && item.num_add_names.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.val_type!==null && item.val_type.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_days!==null && item.val_days.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.val_beg_m_f!==null && item.val_beg_m_f.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_beg_s_s!==null && item.val_beg_s_s.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_end!==null && item.val_end.toLowerCase().includes(filter.toLowerCase())) ||
(item.version_id!==null && item.version_id.toString().toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'tdb_stops':
return data.filter((item, index) => {
return (
item.id.toLowerCase().includes(filter.toLowerCase()) ||
(item.active!==null && item.active.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.lon!==null && item.lon.toLowerCase().includes(filter.toLowerCase())) ||
(item.location!==null && item.location.toLowerCase().includes(filter.toLowerCase())) ||
(item.lat!==null && item.lat.toLowerCase().includes(filter.toLowerCase())) ||
(item.stop_long_name!==null && item.stop_long_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.stop_name!==null && item.stop_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.stop_name_extern!==null && item.stop_name_extern.toLowerCase().includes(filter.toLowerCase())) ||
(item.fare_zone_1!==null && item.fare_zone_1.toLowerCase().includes(filter.toLowerCase())) ||
(item.fare_zone_2!==null && item.fare_zone_2.toLowerCase().includes(filter.toLowerCase())) ||
(item.fare_zone_3!==null && item.fare_zone_3.toLowerCase().includes(filter.toLowerCase())) ||
(item.fare_zone_4!==null && item.fare_zone_4.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_from!==null && item.valid_from.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_until!==null && item.valid_until.toLowerCase().includes(filter.toLowerCase())) ||
(item.last_modified!==null && item.last_modified.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'ticket-selection':
return data.filter((item,index)=>{
return (
item.price_id.toString().toLowerCase().includes(filter.toLowerCase()) ||
(item.ext_prod_de!==null && item.ext_prod_de.toLowerCase().includes(filter.toLowerCase())) ||
(item.id_ticket!==null && item.id_ticket.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.price!==null && item.price.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.name!==null && item.name.toLowerCase().includes(filter.toLowerCase())) ||
(item.short_name!==null && item.short_name.toLowerCase().includes(filter.toLowerCase())) ||
(item.priority!==null && item.priority.toString().toLowerCase().includes(filter.toLowerCase())) ||
(item.duration!==null && item.duration.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_beg_m_f!==null && item.val_beg_m_f.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_beg_s_s!==null && item.val_beg_s_s.toLowerCase().includes(filter.toLowerCase())) ||
(item.val_end!==null && item.val_end.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
case 'tokens':
console.log('filterData() //TODO implement tokens');
return data;
break;
case 'users':
console.log('filterData() //TODO implement users');
return data;
break;
case 'versions':
return data.filter((item, index) => {
return (
item.version_id.toString().toLowerCase().includes(filter.toLowerCase()) ||
(item.name!==null && item.name.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_from!==null && item.valid_from.toLowerCase().includes(filter.toLowerCase())) ||
(item.valid_until!==null && item.valid_until.toLowerCase().includes(filter.toLowerCase())) ||
(item.descr!==null && item.descr.toLowerCase().includes(filter.toLowerCase()))
);
});
break;
default:
console.error(`filterData() name: ${name} unknown`);
return data;
}
}
return data;
};
module.exports = {
filterData
};

View File

@ -1,41 +1,58 @@
import Pbf from 'pbf';
import { FeedMessage } from './gtfs-rt.js';
import charIntoString from './string';
//import charIntoString from './string';
export default function parseMessages(buffer){
//console.log('parseMessages() start...');
/**
* parse buffer into array of entity objects
*/
export function parseMessages(buffer){
////console.log('parseMessages() start...');
const messages = [];
const pbf = new Pbf(buffer);
const feed = FeedMessage.read(pbf);
//console.log('parseMessages() feed:'+JSON.stringify(feed));
////console.log('parseMessages() feed:'+JSON.stringify(feed));
let countEntityAlert = 0;
let countEntityShape = 0;
let countEntityTripUpdate = 0;
let countEntityVehicle = 0;
feed.entity.forEach(entity => {
/*Data about the realtime position of a vehicle.*/
const vehiclePos = entity.vehicle;
if (vehiclePos) {
//console.log('getVehPos() vehiclePos available');
const entityAlert = entity.alert;
const entityShape = entity.shape;
const entityTripUpdate = entity.trip_update;
const entityVehicle = entity.vehicle;
if (entityAlert) {
countEntityAlert++;
} else if (entityShape) {
countEntityShape++;
} else if (entityTripUpdate) {
countEntityTripUpdate++;
}else if (entityVehicle) {
countEntityVehicle++;
////console.log('parseMessage() entityVehicle available');
/*The Trip that this vehicle is serving.*/
const trip=vehiclePos.trip;
const trip=entityVehicle.trip;
/*Additional information on the vehicle that is serving this trip.*/
const vehicle=vehiclePos.vehicle;
const vehicle=entityVehicle.vehicle;
/*Current position of this vehicle.*/
const position=vehiclePos.position;
const position=entityVehicle.position;
/*Moment at which the vehicle's position was measured. In POSIX time (i.e., number of seconds since January 1st 1970 00:00:00 UTC).*/
const vehPosTimestamp=vehiclePos.timestamp;
const vehPosTimestamp=entityVehicle.timestamp;
//remove tailing dot
//match a dot when it is followed by a whitespace or the end of the string
/*TODO Is this precaution required?*/
//TODO Handle error! Placing a decimal point at a fixed place does not work in general!
//let posLat=position.latitude;
//console.log(`getVehPos() posLat:${posLat}`);
////console.log(`parseMessage() posLat:${posLat}`);
//let latFormed = position.latitude === undefined ? -360 : position.latitude.toString().replace(/\.+$/, "");
//console.log(`getVehPos() latFormed:${latFormed}`);
////console.log(`parseMessage() latFormed:${latFormed}`);
//latFormed=charIntoString(latFormed,latFormed.length - 7,'.');
//console.log(`getVehPos() latFormed:${latFormed}`);
////console.log(`parseMessage() latFormed:${latFormed}`);
//let lonFormed = position.longitude === undefined ? -720 : position.longitude.toString().replace(/\.+$/, "");
//lonFormed=charIntoString(lonFormed,lonFormed.length - 7,'.');
//console.log(`getVehPos() lonFormed:${lonFormed}`);
////console.log(`parseMessage() lonFormed:${lonFormed}`);
const now= new Date();
const message={
/*Version of the feed specification. The current version is 2.0.*/
@ -61,9 +78,27 @@ export default function parseMessages(buffer){
};
messages.push(message);
} else {
console.error('getVehPos() vehiclePos NOT available');
console.error('ERROR: parseMessage() entity NOT known');
}
});
//console.log('parseMessages() done.');
//console.log('parseMessages() countEntityAlert: ' + countEntityAlert);
//console.log('parseMessages() countEntityShape: ' + countEntityShape);
//console.log('parseMessages() countEntityTripUpdate: ' + countEntityTripUpdate);
//console.log('parseMessages() countEntityVehicle: ' + countEntityVehicle);
////console.log('parseMessages() done.');
return messages;
};
/**
* read pbf file
* @return array of GTFS RT entities
*/
export function readPbf(buffer){
//console.log('readPbfs() start...');
const messages = [];
const pbf = new Pbf(buffer);
const feed = FeedMessage.read(pbf);
//console.log('readPbfs() length: ' + feed.entity.length);
//console.log('readPbfs() done.');
return feed.entity;
};

View File

@ -0,0 +1,4 @@
const selectOptions = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000];
module.exports = {
selectOptions
};

4
be/.env.vbn Normal file
View File

@ -0,0 +1,4 @@
NODE_ENV=development
#NODE_ENV=production
PORT=65532
URL=http://gtfsr.vbn.de/gtfsr_connect.bin

4
be/.env.vrs Normal file
View File

@ -0,0 +1,4 @@
NODE_ENV=development
#NODE_ENV=production
PORT=65532
URL=https://gtfs-rt-test.vrsinfo.de:4443/buffer/tripUpdate.buf

174
be/index.js Normal file
View File

@ -0,0 +1,174 @@
const db = require('./src/db');
const protoBuf = require('pbf');
const debug = require('debug')('index');
require('dotenv').config();
const gtfsRt = require('../../proto2js/js/gtfs-rt.js');
const gtfsrFetch = require('./src/fetch');
const tUO = require('./src/trip-update-obj');
const timeoutScnds = 60;
async function timeoutFct()
{
console.log('index:setInterval start...');
//initialize array of trip update objects
const rryOfTripUpdateObjcs = new Array();
//fetch GTFS Realtime feed and store trip updates as array of objects
let buffer = null
buffer = await gtfsrFetch.fetch();
if (buffer !== null && buffer !== undefined) {
//TODO clean up debug('index:intervalFunc(): buffer available');
const FeedMessage = gtfsRt.FeedMessage;
const prtBf = new protoBuf(buffer);
const feed = FeedMessage.read(prtBf);
let countTrip = 0;
let countTripId = 0;
let countRouteId = 0;
let countScheduleRelationship = 0;
let countVehicle = 0;
let countTimestamp = 0;
let countDelay = 0;
feed.entity.forEach(entity => {
const entityTripUpdate = entity.trip_update;
if (entityTripUpdate !== null && entityTripUpdate !== undefined) {
const tuo = new tUO.TripUpdate();
const trip = entityTripUpdate.trip;
if (trip !== null && trip !== undefined) {
countTrip++
const t = new tUO.Trip();
const tripId = trip.trip_id;
if (tripId !== null && tripId !== undefined) {
countTripId++;
t.tripId = tripId;
}
const routeId = trip.route_id;
if (routeId !== null && routeId !== undefined) {
countRouteId++;
t.routeId = routeId;
}
const scheduleRelationship = trip.schedule_relationship;
if (scheduleRelationship !== null && scheduleRelationship !== undefined) {
countScheduleRelationship++;
t.scheduleRelationship = scheduleRelationship;
}
//create a copy of an object, you can use the spread operator
tuo.trip = {...t};
}
const vehicle = entityTripUpdate.vehicle;
if (vehicle !== null && vehicle !== undefined) {
countVehicle++;
}
const timestamp = entityTripUpdate.timestamp;
if (timestamp !== null && timestamp !== undefined) {
countTimestamp++;
tuo.timestamp = timestamp;
}
const delay = entityTripUpdate.delay;
if (delay !== null && delay !== undefined) {
countDelay++;
tuo.delay = delay;
}
rryOfTripUpdateObjcs.push({...tuo});
}
});
debug('index:intervalFunc(): rryOfTripUpdateObjcs.length: ' + rryOfTripUpdateObjcs.length);
//TODO clean up
/*
debug('index:intervalFunc(): countTrip: ' + countTrip);
debug('index:intervalFunc(): countTripId: ' + countTripId);
debug('index:intervalFunc(): countRouteId: ' + countRouteId);
debug('index:intervalFunc(): countScheduleRelationship: ' + countScheduleRelationship);
debug('index:intervalFunc(): countVehicle: ' + countVehicle);
debug('index:intervalFunc(): countTimeStamp: ' + countTimestamp);
debug('index:intervalFunc(): countDelay: ' + countDelay);
*/
} else {
console.error('ERROR: index:intervalFunc(): buffer NOT available');
}
//select trip updates of today from db as array of objects
const schema = process.env.DB_SCHEMA || 'schema';
//TODO some GTFSR feeds do not provide timestamp const query = 'SELECT * FROM ' + schema + '.trip_updates WHERE timestamp_gtfsr >= current_date';
//NOTE: https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT
const query = 'SELECT * FROM ' + schema + '.trip_updates WHERE timestamp_pgsql >= current_date';
//TODO clean up debug('query: '+query);
const rsp = await db.query(query);
debug('rsp.length: '+rsp.length);
if (rsp !== null && rsp !== undefined && rsp.length < 0) {
//TODO clean up
debug('rsp[0]: ' + JSON.stringify(rsp[0]));
}
//TODO only continue if rsp is neither null or undefined
//transform array of objects into map [trip_trip_id, trip update object]
//TODO handle rsp NOT available
const mapDbTripUpdates = new Map();
rsp.forEach(element => {
mapDbTripUpdates.set(element.trip_trip_id, element);
});
debug('mapDbTripUpdates.size: ' + mapDbTripUpdates.size);
//compare map from db with array of trip update objcs from GTFS Realtime feed
//TODO clean up
let countRryElem = 0;
let countInsert = 0;
//TODO https://stackoverflow.com/a/37576787/15078958
//TODO according to this link forEach() has challenges for async/await
for (const element of rryOfTripUpdateObjcs) {
const value = mapDbTripUpdates.get(element.trip.tripId);
let query = '';
let rspPsql = null;
if (value === undefined) {
query = `INSERT INTO `
+ schema
+ `.trip_updates (trip_trip_id, trip_route_id, trip_schedule_relationship, timestamp_gtfsr, delay) VALUES ('`
+ element.trip.tripId
+ `', '`
+ element.trip.routeId
+ `', '`
+ element.trip.scheduleRelationship
+ `', to_timestamp(`
+ element.timestamp
+ `), '`
+ element.delay
+ `');`;
rspPsql = await db.query(query);
countInsert++;
if (countRryElem === 0) {
debug('value: ' + JSON.stringify(value));
debug('query: ' + query);
debug('respDb: ' + JSON.stringify(rspPsql));
}
countRryElem++;
}
}
debug('countInsert: ' + countInsert);
//other
console.log('index:setInterval done.');
setTimeout(timeoutFct, timeoutScnds * 1000);
}
async function run() {
debug('index:run(): started...');
timeoutFct();
};
run().catch(err => {
console.error('ERROR: ');
console.log(err)
});

1199
be/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
be/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "gtfsr-dp-be",
"version": "1.0.0",
"description": "GTFS Realtime Display Back End",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js",
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Software Ingenieur Begerad <dialog@SwIngBe.de>",
"license": "GPL-3.0-or-later",
"dependencies": {
"axios": "1.6.2",
"debug": "4.3.4",
"dotenv": "16.3.1",
"pbf": "3.2.1",
"pg": "8.11.3"
},
"devDependencies": {
"nodemon": "3.0.1"
}
}

19
be/readme.md Normal file
View File

@ -0,0 +1,19 @@
# Overview
# General
Requirements:
* Node.js <= 18.13.0
# Preparation
```
npm i
```
# Development Setup
```
DEBUG=config,debug,gtfsr,index npm run dev
```

22
be/src/config.js Normal file
View File

@ -0,0 +1,22 @@
const DEBUG=require('debug')('config');
DEBUG('config start...');
require('dotenv').config();
const config = {
db: { /* do not put password or any sensitive info here, done only for demo */
host: process.env.DB_HOST || 'host',
port: process.env.DB_PORT || '5432',
user: process.env.DB_USER || 'usr',
password: process.env.DB_PASSWORD || 'key',
database: process.env.DB_NAME || 'db',
},
listPerPage: process.env.LIST_PER_PAGE || 10,
};
DEBUG('config host: '+config.db.host);
DEBUG('config port: '+config.db.port);
DEBUG('config user: '+config.db.user);
DEBUG('config database: '+config.db.database);
module.exports = config;
DEBUG('config done.');

22
be/src/db.js Normal file
View File

@ -0,0 +1,22 @@
const { Pool } = require('pg');
const config = require('./config');
const pool = new Pool(config.db);
/**
* Query the database using the pool
* @param {*} query
* @param {*} params
*
* @see https://node-postgres.com/features/pooling#single-query
*/
async function query(query, params) {
const {rows, fields} = await pool.query(query, params);
return rows;
};
module.exports = {
query
};

34
be/src/fetch.js Normal file
View File

@ -0,0 +1,34 @@
const axios=require('axios');
require('dotenv').config()
async function fetch() {
let rsp = null;
try {
//TODO switch from Connect to Mobilithek
const URL = process.env.URL || 'http://localhost:65535/fetch.bin';
const config = {
responseType: 'arraybuffer'
//responseType: 'blob'
};
rsp = await axios.get(URL, config);
if (rsp === null || rsp === undefined) {
console.error('ERROR: fetch(): rsp NOT available');
//} else {
//NOTE: Use byteLength to check the size
//const bytes = rsp.data.byteLength;
//console.log('fetch:fetch() rsp length in bytes: ' + bytes);
}
} catch (err) {
console.error('ERROR: fetch:fetch(): ', err.message);
}
return rsp.data;
};
module.exports={
fetch
};

19
be/src/trip-update-obj.js Normal file
View File

@ -0,0 +1,19 @@
//Object Constructor
function Trip(tripId = '', routeId = '', scheduleRelationship = '') {
this.tripId = tripId;
this.routeId = routeId;
this.scheduleRelationship = scheduleRelationship;
};
//Object Constructor
function TripUpdate(trip = null, timestamp = 0, delay = 0) {
this.trip = trip;
this.timestamp = timestamp;
this.delay = delay;
};
module.exports={
Trip,
TripUpdate
};

20
db/bin/trip-updates.sh Normal file
View File

@ -0,0 +1,20 @@
#!/bin/sh
#
echo "Started..."
#
# special variable $# is the number of arguments
if [ $# -lt 3 ] ; then
echo 'Call ./<script> <db name> <db user> <db schema>'
exit 1
fi
#
DB_NAME="$1"
echo "DB_NAME: ${DB_NAME}"
DB_USER="$2"
echo "DB_USER: ${DB_USER}"
DB_SCHEMA="$3"
echo "DB_SCHEMA: ${DB_SCHEMA}"
#
psql -h localhost -p 5432 -U $DB_USER -f ./sql/trip-updates.sql -d $DB_NAME -v schema=$DB_SCHEMA
#
echo "Done."

20
db/sql/trip-updates.sql Normal file
View File

@ -0,0 +1,20 @@
-- colon before variable: for a prepared statement using named placeholders, this will be a parameter name of the form :name
CREATE SCHEMA IF NOT EXISTS :schema;
SET search_path to :schema, public;
-- create table
DROP TABLE IF EXISTS :schema.trip_updates CASCADE;
CREATE TABLE IF NOT EXISTS :schema.trip_updates
(
trip_trip_id text NULL,
trip_route_id text NULL,
trip_schedule_relationship text NULL,
vehicle_id text NULL,
vehicle_label text NULL,
vehicle_license_plate text NULL,
timestamp_gtfsr timestamptz NULL,
timestamp_pgsql timestamptz NOT NULL DEFAULT now(),
delay int
);

5136
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"private": true,
"name": "gtfs-rt-display",
"description": "display data from GTFS Realtime feeds",
"version": "0.3.0",
"version": "0.4.0",
"main": "index.js",
"keywords": [
"public",
@ -13,12 +13,12 @@
"display"
],
"author": "Software Ingenieur Begerad <dialog@SwIngBe.de>",
"homepage": "https://github.com/Software-Ingenieur-Begerad/gtfs-rt-display/tree/main",
"repository": "https://github.com/Software-Ingenieur-Begerad/gtfs-rt-display",
"bugs": "https://github.com/Software-Ingenieur-Begerad/gtfs-rt-display/issues",
"homepage": "https://www.swingbe.de/activity/gtfs-rt-display/",
"repository": "https://git.wtf-eg.de/dancingCycle/gtfs-rt-display",
"bugs": "https://git.wtf-eg.de/dancingCycle/gtfs-rt-display/issues",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=10"
"node": "<=18.13.0"
},
"scripts": {
"start": "webpack serve --config config/webpack.dev.js",
@ -39,14 +39,14 @@
"webpack-merge": "5.8.0"
},
"dependencies": {
"axios": "0.27.2",
"bootstrap": "5.2.1",
"leaflet": "1.8.0",
"pbf": "^3.2.1",
"axios": "1.4.0",
"bootstrap": "5.3.1",
"leaflet": "1.9.4",
"pbf": "3.2.1",
"react": "18.2.0",
"react-bootstrap": "2.5.0",
"react-bootstrap": "2.8.0",
"react-dom": "18.2.0",
"react-leaflet": "4.0.2",
"react-leaflet": "4.2.1",
"react-router-bootstrap": "0.26.2"
}
}

View File

@ -1,20 +0,0 @@
{
"extends":
[
"config:base"
],
"timezone": "Europe/Berlin",
"packageRules":
[
{
"matchDepTypes": ["devDependencies"],
"matchPackagePatterns": ["lint","prettier"],
"automerge": true
},
{
"matchUpdateTypes": ["minor","patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true
}
]
}