Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83fc10878b | |||
| 576f794ff7 | |||
| bd1db2a468 | |||
| 905214b49e | |||
| 74d3f395ce | |||
| 034e7e1e27 | |||
| 09bffa78a3 | |||
| f0088b08b6 | |||
| 91a43a37b4 | |||
| 4a2aa59c77 | |||
| 3aab0b826b | |||
| 6e03bc997c | |||
| f04bb1843c | |||
| ed621b7ce2 | |||
| 0e9150b5ce | |||
| 6f591c10d5 | |||
| a15dccf3c2 | |||
| f82fe1a524 | |||
| 0b4852c582 | |||
| 37ff82a6ef | |||
| 5d1236dd87 | |||
| 4642ca0115 | |||
| 0a3d7baf82 | |||
| 885a96dced | |||
| f437fb8c5a | |||
| 366d0dd454 | |||
| 80b1b2b0cc | |||
| 269a651325 | |||
| 6386a3420e | |||
| eecb0ed95b | |||
| 99bb3b2981 | |||
| 5fb1462af6 | |||
| e90ea69d44 | |||
| 297d61b065 | |||
| af8107b34c | |||
| 0f72977cb9 | |||
| 886849b949 | |||
| 5f8a797a58 | |||
| 3859c22086 | |||
| 0b9af4b067 | |||
| 20ae4b0606 | |||
| 0dcaf486c0 | |||
| 588b826b42 | |||
| 5d78f40c3e | |||
| 362906b1ba | |||
| 93e4b2b065 | |||
| 35a43cb829 | |||
| 3e61ae7e74 |
123
README.md
123
README.md
@@ -1,78 +1,97 @@
|
|||||||
# Автобус №127 — Расписание (Android)
|
# Автобус №127 — Расписание (Android)
|
||||||
|
|
||||||
[](#лицензия)
|
Приложение с расписанием автобуса **№127** по маршруту
|
||||||
|
**Кандалакша — Полярные Зори — Зашеек** (Мурманская область).
|
||||||
Приложение для Android с расписанием автобуса **№127** по маршруту
|
|
||||||
**Полярные Зори ⇄ Зашеек** (Мурманская область, Кандалакшский район).
|
|
||||||
Написано на **Kotlin** с использованием **Android SDK** и Material Components.
|
|
||||||
Тёмная тема, аккуратные карточки и «чипы» расписания.
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Расписание на **будни**, **субботу** и **воскресенье** (переключатель вкладок).
|
- **Выбор остановки** — Кандалакша / Полярные Зори / Зашеек. При выборе
|
||||||
- Блок **«Ближайшие рейсы»** — три ближайших отправления с подсчётом
|
показываются доступные направления (куда можно уехать).
|
||||||
«через N минут» (время по **МСК**, обновляется каждые 30 сек).
|
- **Ближайшие рейсы** — по 3 на каждое направление, с подсчётом
|
||||||
- Полные списки рейсов **туда** (ПЗ → Зашеек) и **обратно** (Зашеек → ПЗ),
|
«через N ч M мин» (время по **МСК**, обновляется каждые 30 сек).
|
||||||
аккуратно переносящиеся по 5 в строке.
|
- **Автоопределение дня недели** — будни/выходные.
|
||||||
- Тёмная тема (#121212) с оранжево-красным акцентом.
|
- **Полное расписание** — по кнопке раскрывается вся таблица
|
||||||
- Адаптивная иконка-автобус (Adaptive Icon).
|
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
|
||||||
- Живые часы местного времени (МСК) в шапке.
|
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
|
||||||
|
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
|
||||||
## Скриншот
|
|
||||||
|
|
||||||
<sub>Иконка приложения — стилизованный автобус на градиентном фоне.</sub>
|
|
||||||
|
|
||||||
## Технические данные
|
## Технические данные
|
||||||
|
|
||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|-----------------|-----------------------------------|
|
|-----------------|------------------------------|
|
||||||
| Язык | Kotlin 1.9.22 |
|
| Тип | WebView-обёртка (HTML/JS) |
|
||||||
| Минимальный SDK | 26 (Android 8.0) |
|
| Минимальный SDK | 26 (Android 8.0) |
|
||||||
| Целевой SDK | 34 (Android 14) |
|
| Целевой SDK | 34 (Android 14) |
|
||||||
| Сборка | Gradle 8.6 + Android Gradle Plugin 8.2.2 |
|
| Сборка | Gradle 8.6 + AGP 8.2.2 |
|
||||||
| UI | Material Components, ConstraintLayout |
|
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
|
||||||
|
|
||||||
## Сборка
|
## Пуш-уведомления (RuStore)
|
||||||
|
|
||||||
Проект собирается системным Gradle (на хосте `192.168.1.46` gradle распакован
|
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications)
|
||||||
в `~/BusSchedule127/gradle-8.6`):
|
(аналог Firebase Cloud Messaging). Проект в консоли RuStore привязан к пакету
|
||||||
|
`ru.ua1zbe.bus127` и подписи release-keystore; пока подпись не меняется, перерегистрация не нужна.
|
||||||
|
|
||||||
```bash
|
Для доставки на устройстве должен быть установлен и авторизован **RuStore**.
|
||||||
export ANDROID_HOME=~/Android/Sdk
|
|
||||||
export JAVA_HOME=/usr
|
|
||||||
export PATH=$PATH:~/BusSchedule127/gradle-8.6/bin
|
|
||||||
|
|
||||||
cd BusSchedule127
|
Рассылка выполняется с сервера через API RuStore:
|
||||||
gradle clean assembleRelease --no-daemon
|
|
||||||
|
```
|
||||||
|
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
|
||||||
|
Authorization: Bearer {service-token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"token": "{push-token устройства}",
|
||||||
|
"notification": {
|
||||||
|
"title": "Автобус 127",
|
||||||
|
"body": "Ближайший рейс через 5 мин"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Подписанный APK появится в:
|
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
|
||||||
`app/build/outputs/apk/release/app-release.apk`
|
В приложение попадает только `project_id`; сервисный токен в APK не хранится.
|
||||||
|
- `push-token` устройства приходит в `onNewToken` (лог `RuStorePush`) и сохраняется
|
||||||
> Для подписи используется keystore `~/bus127.keystore` (alias `bus127`).
|
в `SharedPreferences` `bus127_push`.
|
||||||
> Секретные ключи в репозиторий не включены — генерируйте свои
|
- Приложение подписывается на топик `bus127` — топик должен существовать в консоли.
|
||||||
> через `keytool` перед релизной сборкой.
|
Сообщения без поля `topic` приходят всем устройствам проекта.
|
||||||
|
- Если в запросе есть секция `notification` — уведомление показывает сам SDK;
|
||||||
|
если переданы только `data` (`title`/`body`, необязательный `url`) — приложение
|
||||||
|
показывает его само и открывает ссылку по клику.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
BusSchedule127/
|
apk-webview/ # исходники APK (WebView)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── build.gradle
|
│ ├── build.gradle
|
||||||
│ └── src/main/
|
│ └── src/main/
|
||||||
│ ├── AndroidManifest.xml
|
│ ├── AndroidManifest.xml
|
||||||
│ ├── java/ru/ua1zbe/bus127/MainActivity.kt
|
│ ├── java/ru/ua1zbe/bus127/
|
||||||
│ └── res/
|
│ │ ├── MainActivity.java # WebView + запрос разрешения на уведомления
|
||||||
│ ├── drawable/ # фоны, чипы, иконка-автобус
|
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
|
||||||
│ ├── layout/ # activity_main.xml
|
│ │ ├── PushMessagingService.java # приём уведомлений
|
||||||
│ ├── mipmap-anydpi-v26/ # адаптивная иконка
|
│ │ ├── PushIntents.java # переход по клику
|
||||||
│ └── values/ # colors, strings, themes
|
│ │ └── push/PushStore.java # хранение push-токена
|
||||||
├── build.gradle
|
│ ├── assets/index.html # интерфейс (HTML/JS)
|
||||||
├── settings.gradle
|
│ └── res/values/strings.xml
|
||||||
├── gradle.properties
|
└── settings.gradle, build.gradle, gradle.properties
|
||||||
└── README.md
|
web/index.html # веб-версия (та же страница, для браузера)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=$HOME/jdk-17.0.20.1+1
|
||||||
|
export ANDROID_HOME=$HOME/Android/Sdk
|
||||||
|
cd apk-webview
|
||||||
|
~/gradle-8.6/bin/gradle assembleRelease --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовый APK — в [релизах](../../releases) и в репозитории `android_apk`.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
MIT — свободное использование и модификация.
|
MIT.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId "ru.ua1zbe.bus127"
|
applicationId "ru.ua1zbe.bus127"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 2
|
versionCode 11
|
||||||
versionName "2.0"
|
versionName "2.6.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -35,3 +35,7 @@ android {
|
|||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "ru.rustore.sdk:pushclient:7.4.0"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".Bus127App"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".PushMessagingService"
|
||||||
|
android:exported="true"
|
||||||
|
tools:ignore="ExportedService">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
|
||||||
|
android:resource="@mipmap/ic_launcher" />
|
||||||
|
<meta-data
|
||||||
|
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
|
||||||
|
android:value="@string/push_channel_id" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
||||||
.wrap{max-width:760px;margin:0 auto}
|
.wrap{max-width:760px;margin:0 auto}
|
||||||
header{text-align:center;margin-bottom:18px}
|
header{text-align:center;margin-bottom:18px}
|
||||||
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
|
h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
|
||||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
|
|
||||||
.sub{color:var(--muted);font-size:14px}
|
.sub{color:var(--muted);font-size:14px}
|
||||||
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
||||||
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
||||||
|
|
||||||
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||||
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
||||||
.card h3 .arr{color:var(--accent)}
|
.card h3 .arr{color:var(--accent)}
|
||||||
@@ -58,6 +58,15 @@
|
|||||||
|
|
||||||
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||||
overflow:hidden;display:none;margin-bottom:8px}
|
overflow:hidden;display:none;margin-bottom:8px}
|
||||||
|
/* переключатель типа дня в полном расписании */
|
||||||
|
.days{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:12px 12px 10px;
|
||||||
|
border-bottom:1px solid var(--line)}
|
||||||
|
.days button{padding:9px 4px;border-radius:10px;border:1px solid var(--line);
|
||||||
|
background:var(--card2);color:var(--muted);font-size:13px;font-weight:600;
|
||||||
|
cursor:pointer;transition:.15s;font-family:inherit}
|
||||||
|
.days button:hover{border-color:var(--accent2);color:var(--txt)}
|
||||||
|
.days button.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
|
||||||
|
color:#04101c;border-color:transparent}
|
||||||
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
||||||
font-size:11px;color:var(--muted);text-align:center}
|
font-size:11px;color:var(--muted);text-align:center}
|
||||||
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
||||||
@@ -76,7 +85,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
|
||||||
<h1>Автобус №127</h1>
|
<h1>Автобус №127</h1>
|
||||||
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
||||||
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
||||||
@@ -95,6 +103,11 @@
|
|||||||
|
|
||||||
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
||||||
<section class="tablecard" id="fullTable">
|
<section class="tablecard" id="fullTable">
|
||||||
|
<div class="days" id="dayPick">
|
||||||
|
<button data-day="weekday" class="active">Будни</button>
|
||||||
|
<button data-day="saturday">Суббота</button>
|
||||||
|
<button data-day="sunday">Воскресенье</button>
|
||||||
|
</div>
|
||||||
<div class="thead">
|
<div class="thead">
|
||||||
<div><b>Кандалакша</b>отпр.</div>
|
<div><b>Кандалакша</b>отпр.</div>
|
||||||
<div><b>Полярные Зори</b>→ Зашеек</div>
|
<div><b>Полярные Зори</b>→ Зашеек</div>
|
||||||
@@ -108,39 +121,126 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SCHEDULE = [
|
// Расписание по типам дней недели.
|
||||||
["05:20","06:20","06:30","06:50"],
|
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
|
||||||
["06:10","07:00","07:25","07:45"],
|
// прибытие в Зашеек · Полярные Зори (обратно).
|
||||||
["07:40","08:30","09:00","09:20"],
|
//
|
||||||
["08:00","08:50","09:20","09:50"],
|
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
|
||||||
["09:45","10:35","11:05","11:25"],
|
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
|
||||||
["11:00","11:50","12:20","12:40"],
|
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
|
||||||
["12:30","13:20","13:50","14:10"],
|
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
|
||||||
["13:30","14:20","14:50","15:10"],
|
// будней, и требуют сверки с официальным расписанием:
|
||||||
["14:00","14:50","15:25","15:50"],
|
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
|
||||||
["15:30","16:20","17:00","17:25"],
|
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
|
||||||
["16:50","17:40","18:10","18:35"],
|
const SCHEDULES = {
|
||||||
["18:20","19:10","19:40","20:05"],
|
weekday: [
|
||||||
["20:20","21:10","21:40","22:05"]
|
["05:20","06:20","06:30","06:50"],
|
||||||
];
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["07:40","08:30","09:00","09:20"],
|
||||||
|
["08:00","08:50","09:20","09:50"],
|
||||||
|
["09:45","10:35","11:05","11:25"],
|
||||||
|
["11:00","11:50","12:20","12:40"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["13:30","14:20","14:50","15:10"],
|
||||||
|
["14:00","14:50","15:25","15:50"],
|
||||||
|
["15:30","16:20","17:00","17:25"],
|
||||||
|
["16:50","17:40","18:10","18:35"],
|
||||||
|
["18:20","19:10","19:40","20:05"],
|
||||||
|
["20:20","21:10","21:40","22:05"]
|
||||||
|
],
|
||||||
|
saturday: [
|
||||||
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["07:30","08:20","08:45","09:05"],
|
||||||
|
["08:45","09:35","10:05","10:25"],
|
||||||
|
["10:10","11:00","11:30","11:50"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["14:00","14:50","15:45","16:05"],
|
||||||
|
["15:30","16:20","16:45","17:05"],
|
||||||
|
["17:00","17:50","18:20","18:40"],
|
||||||
|
["18:10","19:00","19:30","19:50"],
|
||||||
|
["20:20","21:10","21:40","22:00"]
|
||||||
|
],
|
||||||
|
sunday: [
|
||||||
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["08:45","09:35","10:05","10:25"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["14:00","14:50","15:25","15:45"],
|
||||||
|
["16:50","17:40","18:10","18:30"],
|
||||||
|
["20:20","21:10","21:40","22:00"]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const DAY_KEYS = ["weekday","saturday","sunday"];
|
||||||
|
const DAY_TITLES = {weekday:"Будни", saturday:"Суббота", sunday:"Воскресенье"};
|
||||||
|
|
||||||
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
|
|
||||||
|
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
||||||
|
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
|
||||||
|
// (в первой строке 60 — по всей видимости опечатка).
|
||||||
|
const RIDE_PZ_KAND = 50;
|
||||||
|
|
||||||
|
// arrs — список прибытий: {label, col} — время из колонки таблицы,
|
||||||
|
// {label, offset} — «время в пути» от отправления (если col не задан).
|
||||||
const DIRECTIONS = {
|
const DIRECTIONS = {
|
||||||
|
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
||||||
"Кандалакша": [
|
"Кандалакша": [
|
||||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
|
||||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
arrs:[{label:"Полярные Зори", col:1},
|
||||||
|
{label:"Зашеек", col:2}]}
|
||||||
],
|
],
|
||||||
"Полярные Зори": [
|
"Полярные Зори": [
|
||||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
|
||||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
arrs:[{label:"Зашеек", col:2}]},
|
||||||
|
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
|
||||||
|
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
|
||||||
],
|
],
|
||||||
"Зашеек": [
|
"Зашеек": [
|
||||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
|
||||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
arrs:[{label:"Полярные Зори", col:3},
|
||||||
|
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||||
|
|
||||||
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
||||||
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
|
|
||||||
|
// Текущее время по МСК, независимо от часового пояса устройства
|
||||||
|
const MSK_TZ = "Europe/Moscow";
|
||||||
|
let mskFmt = null;
|
||||||
|
try {
|
||||||
|
mskFmt = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: MSK_TZ, hourCycle: "h23",
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||||
|
});
|
||||||
|
} catch(e){ mskFmt = null; }
|
||||||
|
|
||||||
|
function nowMs(){
|
||||||
|
if (mskFmt) {
|
||||||
|
try {
|
||||||
|
const parts = {};
|
||||||
|
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
|
||||||
|
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
|
||||||
|
return {
|
||||||
|
hour: hour,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
|
||||||
|
Number(parts.day))).getUTCDay()
|
||||||
|
};
|
||||||
|
} catch(e){ /* ниже — запасной вариант */ }
|
||||||
|
}
|
||||||
|
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
|
||||||
|
const d = new Date(Date.now() + 3*3600*1000);
|
||||||
|
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
|
||||||
|
second: d.getUTCSeconds(), day: d.getUTCDay() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHM(m){
|
||||||
|
const p = n=>String(n).padStart(2,"0");
|
||||||
|
m = ((m % 1440) + 1440) % 1440; // на случай перехода через полночь
|
||||||
|
return `${p(Math.floor(m/60))}:${p(m%60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtWait(m){
|
function fmtWait(m){
|
||||||
if(m <= 0) return "отправление сейчас";
|
if(m <= 0) return "отправление сейчас";
|
||||||
@@ -152,24 +252,42 @@ function fmtWait(m){
|
|||||||
|
|
||||||
let currentStop = "Полярные Зори";
|
let currentStop = "Полярные Зори";
|
||||||
|
|
||||||
|
// Date#getDay(): 0 — воскресенье, 6 — суббота
|
||||||
|
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
|
||||||
|
|
||||||
|
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
|
||||||
|
function todayKey(){ return dayKeyFor(nowMs().day); }
|
||||||
|
|
||||||
|
// Выбор пользователя — только для полного расписания
|
||||||
|
function sched(){ return SCHEDULES[activeDay]; }
|
||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.getHours()*60 + now.getMinutes();
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
|
const data = SCHEDULES[todayKey()];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = data
|
||||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
.map((r,ri)=>{
|
||||||
|
const dep = toMin(r[d.dep]);
|
||||||
|
const arrs = d.arrs.map(a=>{
|
||||||
|
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
|
||||||
|
return {label:a.label, t:fmtHM(base + (a.offset||0))};
|
||||||
|
});
|
||||||
|
return {dep, arrs, ri};
|
||||||
|
})
|
||||||
.filter(x=>x.dep >= curMin)
|
.filter(x=>x.dep >= curMin)
|
||||||
.sort((a,b)=>a.dep-b.dep)
|
.sort((a,b)=>a.dep-b.dep)
|
||||||
.slice(0,3)
|
.slice(0,TRIPS_SHOWN)
|
||||||
.map(x=>{
|
.map(x=>{
|
||||||
const w = x.dep - curMin;
|
const w = x.dep - curMin;
|
||||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
||||||
|
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
||||||
return `<div class="trip ${w<15?'soon':''}">
|
return `<div class="trip ${w<15?'soon':''}">
|
||||||
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
<span class="t">${data[x.ri][d.dep]}${arr}</span>
|
||||||
<span class="w">${fmtWait(w)}</span>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`;
|
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
|
||||||
return `<div class="card">
|
return `<div class="card">
|
||||||
<h3>→ <span class="arr">${d.to}</span></h3>
|
<h3>→ <span class="arr">${d.to}</span></h3>
|
||||||
<div class="via">${d.via}</div>
|
<div class="via">${d.via}</div>
|
||||||
@@ -179,20 +297,29 @@ function render(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(){
|
function renderTable(){
|
||||||
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row =>
|
document.getElementById("tableBody").innerHTML = sched().map(row =>
|
||||||
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDay(){
|
function renderDay(){
|
||||||
const dow = nowMs().getDay();
|
const dow = nowMs().day;
|
||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
|
||||||
|
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
|
||||||
|
function selectDay(key){
|
||||||
|
activeDay = key;
|
||||||
|
document.querySelectorAll("#dayPick button").forEach(b=>
|
||||||
|
b.classList.toggle("active", b.dataset.day === key));
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
function tickClock(){
|
function tickClock(){
|
||||||
const d = nowMs();
|
const d = nowMs();
|
||||||
const p = n=>String(n).padStart(2,"0");
|
const p = n=>String(n).padStart(2,"0");
|
||||||
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`;
|
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".pick").forEach(btn=>{
|
document.querySelectorAll(".pick").forEach(btn=>{
|
||||||
@@ -207,6 +334,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("#dayPick button").forEach(btn=>{
|
||||||
|
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("fullBtn").addEventListener("click",()=>{
|
document.getElementById("fullBtn").addEventListener("click",()=>{
|
||||||
const t = document.getElementById("fullTable");
|
const t = document.getElementById("fullTable");
|
||||||
const b = document.getElementById("fullBtn");
|
const b = document.getElementById("fullBtn");
|
||||||
@@ -215,7 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
|
|||||||
b.classList.toggle("open", !open);
|
b.classList.toggle("open", !open);
|
||||||
});
|
});
|
||||||
|
|
||||||
tickClock(); renderDay(); render(); renderTable();
|
let activeDay = todayKey(); // по умолчанию — реальный день недели
|
||||||
|
selectDay(activeDay);
|
||||||
|
tickClock(); renderDay(); render();
|
||||||
setInterval(tickClock,1000);
|
setInterval(tickClock,1000);
|
||||||
setInterval(render,30000);
|
setInterval(render,30000);
|
||||||
setInterval(renderDay,60000);
|
setInterval(renderDay,60000);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ru.ua1zbe.bus127;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import ru.rustore.sdk.pushclient.RuStorePushClient;
|
||||||
|
import ru.ua1zbe.bus127.push.PushStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация RuStore Push SDK.
|
||||||
|
*
|
||||||
|
* project_id и topic берутся из консоли RuStore (Push-уведомления > Проекты).
|
||||||
|
* Сервисный токен в приложении не хранится — он нужен только серверу
|
||||||
|
* для вызова https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
|
||||||
|
*/
|
||||||
|
public class Bus127App extends Application {
|
||||||
|
|
||||||
|
public static final String CHANNEL_ID = "bus127_push";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
createPushChannel();
|
||||||
|
initRuStorePush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createPushChannel() {
|
||||||
|
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||||
|
nm.createNotificationChannel(new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.push_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initRuStorePush() {
|
||||||
|
try {
|
||||||
|
RuStorePushClient.INSTANCE.init(this, RUSTORE_PROJECT_ID);
|
||||||
|
RuStorePushClient.INSTANCE.getToken()
|
||||||
|
.addOnSuccessListener(token -> {
|
||||||
|
Log.i(TAG, "push token: " + token);
|
||||||
|
PushStore.setToken(this, token);
|
||||||
|
RuStorePushClient.INSTANCE.subscribeToTopic(PUSH_TOPIC)
|
||||||
|
.addOnSuccessListener(v ->
|
||||||
|
Log.i(TAG, "subscribed to " + PUSH_TOPIC))
|
||||||
|
.addOnFailureListener(e ->
|
||||||
|
Log.w(TAG, "subscribe to " + PUSH_TOPIC + " failed", e));
|
||||||
|
})
|
||||||
|
.addOnFailureListener(e ->
|
||||||
|
Log.w(TAG, "getToken failed", e));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "init failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "RuStorePush";
|
||||||
|
private static final String RUSTORE_PROJECT_ID = "upSEOtZyXP96tskl4hf-442AqciCUTxs";
|
||||||
|
private static final String PUSH_TOPIC = "bus127";
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package ru.ua1zbe.bus127;
|
package ru.ua1zbe.bus127;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowManager;
|
||||||
import android.webkit.WebSettings;
|
import android.webkit.WebSettings;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
import android.webkit.WebViewClient;
|
import android.webkit.WebViewClient;
|
||||||
@@ -10,6 +14,9 @@ public class MainActivity extends Activity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
// экран не гаснет, пока открыто расписание
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
|
requestNotificationsPermission();
|
||||||
WebView webView = new WebView(this);
|
WebView webView = new WebView(this);
|
||||||
WebSettings settings = webView.getSettings();
|
WebSettings settings = webView.getSettings();
|
||||||
settings.setJavaScriptEnabled(true);
|
settings.setJavaScriptEnabled(true);
|
||||||
@@ -19,4 +26,13 @@ public class MainActivity extends Activity {
|
|||||||
webView.loadUrl("file:///android_asset/index.html");
|
webView.loadUrl("file:///android_asset/index.html");
|
||||||
setContentView(webView);
|
setContentView(webView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android 13+ требует явного согласия на показ уведомлений
|
||||||
|
private void requestNotificationsPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.ua1zbe.bus127;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
/** Открытие расписания по клику на уведомление. */
|
||||||
|
final class PushIntents {
|
||||||
|
|
||||||
|
private PushIntents() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static PendingIntent web(Context context, String url) {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
return PendingIntent.getActivity(context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package ru.ua1zbe.bus127;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException;
|
||||||
|
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage;
|
||||||
|
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService;
|
||||||
|
import ru.ua1zbe.bus127.push.PushStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приём пуш-уведомлений RuStore.
|
||||||
|
*
|
||||||
|
* Если сервер прислал секцию notification — SDK показывает уведомление сам.
|
||||||
|
* Если пришли только data (title/body) — показываем уведомление сами.
|
||||||
|
*/
|
||||||
|
public class PushMessagingService extends RuStoreMessagingService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNewToken(String token) {
|
||||||
|
Log.i(TAG, "onNewToken: " + token);
|
||||||
|
PushStore.setToken(this, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageReceived(RemoteMessage message) {
|
||||||
|
if (message.getNotification() != null) {
|
||||||
|
Log.i(TAG, "push displayed by SDK: " + message.getData());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> data = message.getData();
|
||||||
|
String title = data.get("title");
|
||||||
|
if (title == null || title.isEmpty()) {
|
||||||
|
title = getString(R.string.app_name);
|
||||||
|
}
|
||||||
|
String body = data.get("body");
|
||||||
|
if (body == null) {
|
||||||
|
body = "";
|
||||||
|
}
|
||||||
|
if (!title.isEmpty() || !body.isEmpty()) {
|
||||||
|
showPush(title, body, data.get("url"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDeletedMessages() {
|
||||||
|
Log.i(TAG, "onDeletedMessages");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(List<? extends RuStorePushClientException> errors) {
|
||||||
|
for (RuStorePushClientException e : errors) {
|
||||||
|
Log.w(TAG, "error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPush(String title, String body, String url) {
|
||||||
|
Notification.Builder b = new Notification.Builder(this, Bus127App.CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setAutoCancel(true);
|
||||||
|
if (url != null && !url.isEmpty()) {
|
||||||
|
b.setContentIntent(PushIntents.web(this, url));
|
||||||
|
}
|
||||||
|
getSystemService(NotificationManager.class).notify(NOTIFICATION_ID, b.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "RuStorePush";
|
||||||
|
private static final int NOTIFICATION_ID = 127;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.ua1zbe.bus127.push;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
/** Хранение push-токена устройства, чтобы отправлять ему уведомления с сервера. */
|
||||||
|
public final class PushStore {
|
||||||
|
|
||||||
|
private static final String PREFS = "bus127_push";
|
||||||
|
private static final String KEY_TOKEN = "push_token";
|
||||||
|
|
||||||
|
private PushStore() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SharedPreferences sp(Context context) {
|
||||||
|
return context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getToken(Context context) {
|
||||||
|
return sp(context).getString(KEY_TOKEN, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setToken(Context context, String token) {
|
||||||
|
sp(context).edit().putString(KEY_TOKEN, token).apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#1E1E1E" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- bus body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M22,44 L82,44 a8,8 0 0 1 8,8 L90,80 a6,6 0 0 1 -6,6 L24,86 a6,6 0 0 1 -6,-6 L18,52 a8,8 0 0 1 8,-8 Z" />
|
||||||
|
<!-- windshield -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M74,48 L84,48 a4,4 0 0 1 4,4 L88,62 L74,62 Z" />
|
||||||
|
<!-- side windows -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M24,48 L70,48 L70,62 L24,62 Z" />
|
||||||
|
<!-- window dividers -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M38,48 L38,62 M52,48 L52,62 M62,48 L62,62" />
|
||||||
|
<!-- headlight -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFE0D6"
|
||||||
|
android:pathData="M84,70 L88,70 L88,76 L84,76 Z" />
|
||||||
|
<!-- wheels -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M34,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E1E1E"
|
||||||
|
android:pathData="M72,86 m-8,0 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0" />
|
||||||
|
<!-- wheel hubs -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M34,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8A65"
|
||||||
|
android:pathData="M72,86 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Автобус 127</string>
|
<string name="app_name">Автобус 127</string>
|
||||||
|
<string name="push_channel_id">bus127_push</string>
|
||||||
|
<string name="push_channel_name">Пуш-уведомления</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ pluginManagement {
|
|||||||
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||||
}
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositories { google(); mavenCentral() }
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url "https://nexus-external.rustore.ru/repository/maven-rustore-exposed/" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "BusSchedule127"
|
rootProject.name = "BusSchedule127"
|
||||||
include ':app'
|
include ':app'
|
||||||
|
|||||||
205
web/index.html
205
web/index.html
@@ -16,8 +16,7 @@
|
|||||||
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
color:var(--txt);min-height:100vh;padding:24px 16px 48px}
|
||||||
.wrap{max-width:760px;margin:0 auto}
|
.wrap{max-width:760px;margin:0 auto}
|
||||||
header{text-align:center;margin-bottom:18px}
|
header{text-align:center;margin-bottom:18px}
|
||||||
.route{font-size:12px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
|
h1{font-size:clamp(22px,4.5vw,30px);margin:0 0 4px;font-weight:700}
|
||||||
h1{font-size:clamp(22px,4.5vw,30px);margin:6px 0 4px;font-weight:700}
|
|
||||||
.sub{color:var(--muted);font-size:14px}
|
.sub{color:var(--muted);font-size:14px}
|
||||||
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
.clock{font-size:22px;font-weight:600;margin-top:10px;color:var(--accent)}
|
||||||
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
.clock small{color:var(--muted);font-weight:400;font-size:12px}
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
.where{text-align:center;color:var(--muted);font-size:13px;margin:2px 0 12px}
|
||||||
|
|
||||||
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
.dir{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||||
|
.dir .card:only-child{grid-column:1 / -1} /* одна карточка — во всю ширину */
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||||
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
.card h3{margin:0 0 4px;font-size:15px;font-weight:700}
|
||||||
.card h3 .arr{color:var(--accent)}
|
.card h3 .arr{color:var(--accent)}
|
||||||
@@ -58,6 +58,15 @@
|
|||||||
|
|
||||||
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||||
overflow:hidden;display:none;margin-bottom:8px}
|
overflow:hidden;display:none;margin-bottom:8px}
|
||||||
|
/* переключатель типа дня в полном расписании */
|
||||||
|
.days{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:12px 12px 10px;
|
||||||
|
border-bottom:1px solid var(--line)}
|
||||||
|
.days button{padding:9px 4px;border-radius:10px;border:1px solid var(--line);
|
||||||
|
background:var(--card2);color:var(--muted);font-size:13px;font-weight:600;
|
||||||
|
cursor:pointer;transition:.15s;font-family:inherit}
|
||||||
|
.days button:hover{border-color:var(--accent2);color:var(--txt)}
|
||||||
|
.days button.active{background:linear-gradient(135deg,var(--accent2),var(--accent));
|
||||||
|
color:#04101c;border-color:transparent}
|
||||||
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
.thead{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;background:var(--card2);
|
||||||
font-size:11px;color:var(--muted);text-align:center}
|
font-size:11px;color:var(--muted);text-align:center}
|
||||||
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
.thead div{padding:11px 6px;border-left:1px solid var(--line)}
|
||||||
@@ -76,7 +85,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
<div class="route">Мурманская область · регулярный маршрут</div>
|
|
||||||
<h1>Автобус №127</h1>
|
<h1>Автобус №127</h1>
|
||||||
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
|
||||||
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
|
||||||
@@ -95,6 +103,11 @@
|
|||||||
|
|
||||||
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
|
||||||
<section class="tablecard" id="fullTable">
|
<section class="tablecard" id="fullTable">
|
||||||
|
<div class="days" id="dayPick">
|
||||||
|
<button data-day="weekday" class="active">Будни</button>
|
||||||
|
<button data-day="saturday">Суббота</button>
|
||||||
|
<button data-day="sunday">Воскресенье</button>
|
||||||
|
</div>
|
||||||
<div class="thead">
|
<div class="thead">
|
||||||
<div><b>Кандалакша</b>отпр.</div>
|
<div><b>Кандалакша</b>отпр.</div>
|
||||||
<div><b>Полярные Зори</b>→ Зашеек</div>
|
<div><b>Полярные Зори</b>→ Зашеек</div>
|
||||||
@@ -108,39 +121,126 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SCHEDULE = [
|
// Расписание по типам дней недели.
|
||||||
["05:20","06:20","06:30","06:50"],
|
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
|
||||||
["06:10","07:00","07:25","07:45"],
|
// прибытие в Зашеек · Полярные Зори (обратно).
|
||||||
["07:40","08:30","09:00","09:20"],
|
//
|
||||||
["08:00","08:50","09:20","09:50"],
|
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
|
||||||
["09:45","10:35","11:05","11:25"],
|
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
|
||||||
["11:00","11:50","12:20","12:40"],
|
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
|
||||||
["12:30","13:20","13:50","14:10"],
|
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
|
||||||
["13:30","14:20","14:50","15:10"],
|
// будней, и требуют сверки с официальным расписанием:
|
||||||
["14:00","14:50","15:25","15:50"],
|
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
|
||||||
["15:30","16:20","17:00","17:25"],
|
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
|
||||||
["16:50","17:40","18:10","18:35"],
|
const SCHEDULES = {
|
||||||
["18:20","19:10","19:40","20:05"],
|
weekday: [
|
||||||
["20:20","21:10","21:40","22:05"]
|
["05:20","06:20","06:30","06:50"],
|
||||||
];
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["07:40","08:30","09:00","09:20"],
|
||||||
|
["08:00","08:50","09:20","09:50"],
|
||||||
|
["09:45","10:35","11:05","11:25"],
|
||||||
|
["11:00","11:50","12:20","12:40"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["13:30","14:20","14:50","15:10"],
|
||||||
|
["14:00","14:50","15:25","15:50"],
|
||||||
|
["15:30","16:20","17:00","17:25"],
|
||||||
|
["16:50","17:40","18:10","18:35"],
|
||||||
|
["18:20","19:10","19:40","20:05"],
|
||||||
|
["20:20","21:10","21:40","22:05"]
|
||||||
|
],
|
||||||
|
saturday: [
|
||||||
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["07:30","08:20","08:45","09:05"],
|
||||||
|
["08:45","09:35","10:05","10:25"],
|
||||||
|
["10:10","11:00","11:30","11:50"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["14:00","14:50","15:45","16:05"],
|
||||||
|
["15:30","16:20","16:45","17:05"],
|
||||||
|
["17:00","17:50","18:20","18:40"],
|
||||||
|
["18:10","19:00","19:30","19:50"],
|
||||||
|
["20:20","21:10","21:40","22:00"]
|
||||||
|
],
|
||||||
|
sunday: [
|
||||||
|
["06:10","07:00","07:25","07:45"],
|
||||||
|
["08:45","09:35","10:05","10:25"],
|
||||||
|
["12:30","13:20","13:50","14:10"],
|
||||||
|
["14:00","14:50","15:25","15:45"],
|
||||||
|
["16:50","17:40","18:10","18:30"],
|
||||||
|
["20:20","21:10","21:40","22:00"]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const DAY_KEYS = ["weekday","saturday","sunday"];
|
||||||
|
const DAY_TITLES = {weekday:"Будни", saturday:"Суббота", sunday:"Воскресенье"};
|
||||||
|
|
||||||
|
const TRIPS_SHOWN = 5; // сколько ближайших рейсов показывать в карточке
|
||||||
|
|
||||||
|
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
|
||||||
|
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
|
||||||
|
// (в первой строке 60 — по всей видимости опечатка).
|
||||||
|
const RIDE_PZ_KAND = 50;
|
||||||
|
|
||||||
|
// arrs — список прибытий: {label, col} — время из колонки таблицы,
|
||||||
|
// {label, offset} — «время в пути» от отправления (если col не задан).
|
||||||
const DIRECTIONS = {
|
const DIRECTIONS = {
|
||||||
|
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
|
||||||
"Кандалакша": [
|
"Кандалакша": [
|
||||||
{to:"Полярные Зори", dep:0, arr:1, via:"через 1 час"},
|
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
|
||||||
{to:"Зашеек", dep:0, arr:2, via:"через Полярные Зори"}
|
arrs:[{label:"Полярные Зори", col:1},
|
||||||
|
{label:"Зашеек", col:2}]}
|
||||||
],
|
],
|
||||||
"Полярные Зори": [
|
"Полярные Зори": [
|
||||||
{to:"Зашеек", dep:1, arr:2, via:"≈ 30 мин в пути"},
|
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
|
||||||
{to:"Кандалакша", dep:3, arr:null, via:"≈ 1 час в пути"}
|
arrs:[{label:"Зашеек", col:2}]},
|
||||||
|
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
|
||||||
|
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
|
||||||
],
|
],
|
||||||
"Зашеек": [
|
"Зашеек": [
|
||||||
{to:"Полярные Зори", dep:2, arr:3, via:"≈ 20 мин в пути"},
|
{to:"Полярные Зори, Кандалакша", dep:2, via:"через Полярные Зори",
|
||||||
{to:"Кандалакша", dep:2, arr:null, via:"через Полярные Зори"}
|
arrs:[{label:"Полярные Зори", col:3},
|
||||||
|
{label:"Кандалакша", col:3, offset:RIDE_PZ_KAND}]}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
const DAYS = ["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"];
|
||||||
|
|
||||||
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
function toMin(t){ const [h,m]=t.split(":").map(Number); return h*60+m; }
|
||||||
function nowMs(){ return new Date(Date.now()+3*3600*1000); } // МСК = UTC+3
|
|
||||||
|
// Текущее время по МСК, независимо от часового пояса устройства
|
||||||
|
const MSK_TZ = "Europe/Moscow";
|
||||||
|
let mskFmt = null;
|
||||||
|
try {
|
||||||
|
mskFmt = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: MSK_TZ, hourCycle: "h23",
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||||
|
});
|
||||||
|
} catch(e){ mskFmt = null; }
|
||||||
|
|
||||||
|
function nowMs(){
|
||||||
|
if (mskFmt) {
|
||||||
|
try {
|
||||||
|
const parts = {};
|
||||||
|
mskFmt.formatToParts(new Date()).forEach(x => parts[x.type] = x.value);
|
||||||
|
const hour = Number(parts.hour) % 24; // на части движков полночь приходит как "24"
|
||||||
|
return {
|
||||||
|
hour: hour,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
day: new Date(Date.UTC(Number(parts.year), Number(parts.month) - 1,
|
||||||
|
Number(parts.day))).getUTCDay()
|
||||||
|
};
|
||||||
|
} catch(e){ /* ниже — запасной вариант */ }
|
||||||
|
}
|
||||||
|
// Запасной вариант: МСК = UTC+3 (переход на летнее время отменён)
|
||||||
|
const d = new Date(Date.now() + 3*3600*1000);
|
||||||
|
return { hour: d.getUTCHours(), minute: d.getUTCMinutes(),
|
||||||
|
second: d.getUTCSeconds(), day: d.getUTCDay() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHM(m){
|
||||||
|
const p = n=>String(n).padStart(2,"0");
|
||||||
|
m = ((m % 1440) + 1440) % 1440; // на случай перехода через полночь
|
||||||
|
return `${p(Math.floor(m/60))}:${p(m%60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtWait(m){
|
function fmtWait(m){
|
||||||
if(m <= 0) return "отправление сейчас";
|
if(m <= 0) return "отправление сейчас";
|
||||||
@@ -152,24 +252,42 @@ function fmtWait(m){
|
|||||||
|
|
||||||
let currentStop = "Полярные Зори";
|
let currentStop = "Полярные Зори";
|
||||||
|
|
||||||
|
// Date#getDay(): 0 — воскресенье, 6 — суббота
|
||||||
|
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
|
||||||
|
|
||||||
|
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
|
||||||
|
function todayKey(){ return dayKeyFor(nowMs().day); }
|
||||||
|
|
||||||
|
// Выбор пользователя — только для полного расписания
|
||||||
|
function sched(){ return SCHEDULES[activeDay]; }
|
||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const curMin = now.getHours()*60 + now.getMinutes();
|
const curMin = now.hour*60 + now.minute;
|
||||||
const dirs = DIRECTIONS[currentStop];
|
const dirs = DIRECTIONS[currentStop];
|
||||||
|
const data = SCHEDULES[todayKey()];
|
||||||
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
document.getElementById("dirBox").innerHTML = dirs.map(d=>{
|
||||||
const upcoming = SCHEDULE
|
const upcoming = data
|
||||||
.map((r,ri)=>({dep:toMin(r[d.dep]), arr:d.arr!==null?r[d.arr]:null, ri}))
|
.map((r,ri)=>{
|
||||||
|
const dep = toMin(r[d.dep]);
|
||||||
|
const arrs = d.arrs.map(a=>{
|
||||||
|
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
|
||||||
|
return {label:a.label, t:fmtHM(base + (a.offset||0))};
|
||||||
|
});
|
||||||
|
return {dep, arrs, ri};
|
||||||
|
})
|
||||||
.filter(x=>x.dep >= curMin)
|
.filter(x=>x.dep >= curMin)
|
||||||
.sort((a,b)=>a.dep-b.dep)
|
.sort((a,b)=>a.dep-b.dep)
|
||||||
.slice(0,3)
|
.slice(0,TRIPS_SHOWN)
|
||||||
.map(x=>{
|
.map(x=>{
|
||||||
const w = x.dep - curMin;
|
const w = x.dep - curMin;
|
||||||
const arr = x.arr ? `<small>прибытие ${x.arr}</small>` : "";
|
// .trip .t small имеет display:block, поэтому каждый <small> — своя строка
|
||||||
|
const arr = x.arrs.map(a=>`<small>прибытие ${a.label} ${a.t}</small>`).join("");
|
||||||
return `<div class="trip ${w<15?'soon':''}">
|
return `<div class="trip ${w<15?'soon':''}">
|
||||||
<span class="t">${SCHEDULE[x.ri][d.dep]}${arr}</span>
|
<span class="t">${data[x.ri][d.dep]}${arr}</span>
|
||||||
<span class="w">${fmtWait(w)}</span>
|
<span class="w">${fmtWait(w)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${SCHEDULE[0][d.dep]}</div>`;
|
}).join("") || `<div class="empty">на сегодня всё — первый завтра в ${data[0][d.dep]}</div>`;
|
||||||
return `<div class="card">
|
return `<div class="card">
|
||||||
<h3>→ <span class="arr">${d.to}</span></h3>
|
<h3>→ <span class="arr">${d.to}</span></h3>
|
||||||
<div class="via">${d.via}</div>
|
<div class="via">${d.via}</div>
|
||||||
@@ -179,20 +297,29 @@ function render(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(){
|
function renderTable(){
|
||||||
document.getElementById("tableBody").innerHTML = SCHEDULE.map(row =>
|
document.getElementById("tableBody").innerHTML = sched().map(row =>
|
||||||
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
`<div class="row"><div>${row[0]}</div><div>${row[1]}</div><div>${row[2]}</div><div>${row[3]}</div></div>`
|
||||||
).join("");
|
).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDay(){
|
function renderDay(){
|
||||||
const dow = nowMs().getDay();
|
const dow = nowMs().day;
|
||||||
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
|
||||||
|
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
|
||||||
|
function selectDay(key){
|
||||||
|
activeDay = key;
|
||||||
|
document.querySelectorAll("#dayPick button").forEach(b=>
|
||||||
|
b.classList.toggle("active", b.dataset.day === key));
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
function tickClock(){
|
function tickClock(){
|
||||||
const d = nowMs();
|
const d = nowMs();
|
||||||
const p = n=>String(n).padStart(2,"0");
|
const p = n=>String(n).padStart(2,"0");
|
||||||
document.getElementById("clock").innerHTML = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} <small>МСК</small>`;
|
document.getElementById("clock").innerHTML = `${p(d.hour)}:${p(d.minute)}:${p(d.second)} <small>МСК</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".pick").forEach(btn=>{
|
document.querySelectorAll(".pick").forEach(btn=>{
|
||||||
@@ -207,6 +334,10 @@ document.querySelectorAll(".pick").forEach(btn=>{
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("#dayPick button").forEach(btn=>{
|
||||||
|
btn.addEventListener("click",()=>selectDay(btn.dataset.day));
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("fullBtn").addEventListener("click",()=>{
|
document.getElementById("fullBtn").addEventListener("click",()=>{
|
||||||
const t = document.getElementById("fullTable");
|
const t = document.getElementById("fullTable");
|
||||||
const b = document.getElementById("fullBtn");
|
const b = document.getElementById("fullBtn");
|
||||||
@@ -215,7 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
|
|||||||
b.classList.toggle("open", !open);
|
b.classList.toggle("open", !open);
|
||||||
});
|
});
|
||||||
|
|
||||||
tickClock(); renderDay(); render(); renderTable();
|
let activeDay = todayKey(); // по умолчанию — реальный день недели
|
||||||
|
selectDay(activeDay);
|
||||||
|
tickClock(); renderDay(); render();
|
||||||
setInterval(tickClock,1000);
|
setInterval(tickClock,1000);
|
||||||
setInterval(render,30000);
|
setInterval(render,30000);
|
||||||
setInterval(renderDay,60000);
|
setInterval(renderDay,60000);
|
||||||
|
|||||||
Reference in New Issue
Block a user