step-overflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +111 -0
- package/README.md +111 -0
- package/dist/cli.js +28 -0
- package/dist/commands/add.js +188 -0
- package/dist/commands/config.js +92 -0
- package/dist/commands/init.js +193 -0
- package/dist/commands/log.js +41 -0
- package/dist/commands/open.js +78 -0
- package/dist/commands/status.js +55 -0
- package/dist/commands/sync.js +25 -0
- package/dist/lib/achievements.js +322 -0
- package/dist/lib/animation.js +70 -0
- package/dist/lib/calories.js +32 -0
- package/dist/lib/config.js +38 -0
- package/dist/lib/csv.js +55 -0
- package/dist/lib/git.js +97 -0
- package/dist/lib/html.js +437 -0
- package/dist/lib/open-url.js +15 -0
- package/dist/lib/prompt.js +19 -0
- package/dist/lib/routes.js +348 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Qiichos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# step-overflow
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
ウォーキング記録CLIツール。歩いた記録をGitHubに保存し、GitHub Pagesで可視化します。
|
|
6
|
+
|
|
7
|
+
## インストール
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g step-overflow
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 必要なもの
|
|
14
|
+
|
|
15
|
+
- Node.js >= 18
|
|
16
|
+
- git
|
|
17
|
+
- [GitHub CLI](https://cli.github.com/) (`gh`)
|
|
18
|
+
|
|
19
|
+
## クイックスタート
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 初期設定(GitHubリポジトリ作成、速度・体重設定、ルート選択)
|
|
23
|
+
stp init
|
|
24
|
+
|
|
25
|
+
# 60分のウォーキングを記録
|
|
26
|
+
stp add 60
|
|
27
|
+
|
|
28
|
+
# ダッシュボードをブラウザで開く
|
|
29
|
+
stp open
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## コマンド
|
|
33
|
+
|
|
34
|
+
| コマンド | 説明 |
|
|
35
|
+
|---------|------|
|
|
36
|
+
| `stp init` | 初期設定(リポジトリ、速度、体重、ルート) |
|
|
37
|
+
| `stp add <分>` | ウォーキングを記録 |
|
|
38
|
+
| `stp sync` | 未プッシュのコミットを送信 |
|
|
39
|
+
| `stp open` | ダッシュボードをローカルで開く |
|
|
40
|
+
| `stp open --remote` | GitHub Pagesで開く(公開リポジトリ) |
|
|
41
|
+
| `stp status` | 現在の状態、ルート進捗、実績を表示 |
|
|
42
|
+
| `stp config speed <値>` | デフォルト速度を変更(km/h) |
|
|
43
|
+
| `stp config weight <値>` | 体重を変更(kg、`none`でクリア) |
|
|
44
|
+
| `stp config route` | ルートを変更 |
|
|
45
|
+
| `stp log` | 最近の記録をターミナルに表示 |
|
|
46
|
+
|
|
47
|
+
### `stp add` オプション
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
stp add 60 # デフォルト速度で60分
|
|
51
|
+
stp add 60 --speed 5.0 # 5 km/hで60分
|
|
52
|
+
stp add 60 --date 2026-03-01 # 日付を指定して記録
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## ジャーニー
|
|
56
|
+
|
|
57
|
+
初期設定時にルートを選び、歩いた距離に応じて仮想の旅を進めます:
|
|
58
|
+
|
|
59
|
+
| タイトル | ルート | 距離 | 難易度 |
|
|
60
|
+
|---------|-------|------|--------|
|
|
61
|
+
| ⛷️ Tour du Mont Blanc | Chamonix → Chamonix | 170 km | ★☆☆☆☆ |
|
|
62
|
+
| 🏯 Tokaido | Tokyo → Osaka | 495 km | ★☆☆☆☆ |
|
|
63
|
+
| 🕊️ Camino de Santiago | Lisbon → Santiago | 620 km | ★★☆☆☆ |
|
|
64
|
+
| 🏛️ Nile Valley | Aswan → Cairo | 1,100 km | ★★☆☆☆ |
|
|
65
|
+
| 🐘 Hannibal's March | Cartagena → Rome | 1,500 km | ★★☆☆☆ |
|
|
66
|
+
| ⛪ Via Francigena | Canterbury → Rome | 1,900 km | ★★★☆☆ |
|
|
67
|
+
| 🏜️ Trans-Saharan | Timbuktu → Marrakech | 2,200 km | ★★★☆☆ |
|
|
68
|
+
| 🏔️ Qhapaq Ñan | Quito → Cusco | 2,500 km | ★★★☆☆ |
|
|
69
|
+
| ⛵ Spice Route | Banda Neira → Singapore | 3,500 km | ★★★☆☆ |
|
|
70
|
+
| 🇺🇸 Route 66 | Chicago → Los Angeles | 3,940 km | ★★★☆☆ |
|
|
71
|
+
| 🐫 Silk Road | Xi'an → Constantinople | 7,000 km | ★★★★☆ |
|
|
72
|
+
| 🌍 Around the World | London → London | 40,075 km | ★★★★★ |
|
|
73
|
+
|
|
74
|
+
各ルートには中継地点があり、到着するたびに通知されます。ルートを完走したら、次の冒険を選びましょう。
|
|
75
|
+
|
|
76
|
+
## 実績
|
|
77
|
+
|
|
78
|
+
49の実績を歩いてアンロック:
|
|
79
|
+
|
|
80
|
+
- **距離**: First Step, Century, Thousand Miles, 10K Club, ...
|
|
81
|
+
- **ルート**: Tokaido Master, Pilgrim, Silk Merchant, Globe Trotter, ...
|
|
82
|
+
- **1回の記録**: 5K Walk, Half Marathon, Marathon, Speed Demon, ...
|
|
83
|
+
- **時間・習慣**: Early Bird, Night Owl, Weekend Warrior, Double Up
|
|
84
|
+
- **記録回数**: 5, 10, 50, 100回、以降100回刻みで1,000回まで
|
|
85
|
+
|
|
86
|
+
## データ
|
|
87
|
+
|
|
88
|
+
記録は `~/.local/share/step-overflow/<リポジトリ>/data/walking.csv` に保存:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
datetime,time_min,speed_kmh,distance_km,weight_kg
|
|
92
|
+
2026-03-06T07:30:00,60,4.0,4.00,70
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
設定は `~/.config/step-overflow/config.json` に保存。
|
|
96
|
+
|
|
97
|
+
パスは [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/) に準拠。`$XDG_CONFIG_HOME` と `$XDG_DATA_HOME` で変更可能。
|
|
98
|
+
|
|
99
|
+
## ダッシュボード
|
|
100
|
+
|
|
101
|
+
Webダッシュボード(`stp open`)は3つのタブで構成:
|
|
102
|
+
|
|
103
|
+
- **Walking**: サマリーカード、日次/累計チャート、記録テーブル
|
|
104
|
+
- **Journey**: ルート進捗の可視化と中継地点
|
|
105
|
+
- **Achievements**: 実績の一覧(アンロック済み/未アンロック)
|
|
106
|
+
|
|
107
|
+
カロリーはMETs(代謝当量)に基づき、歩行速度から計算されます。
|
|
108
|
+
|
|
109
|
+
## ライセンス
|
|
110
|
+
|
|
111
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# step-overflow
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
CLI walking log tool. Record walks, sync to GitHub, visualize on GitHub Pages.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g step-overflow
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js >= 18
|
|
16
|
+
- git
|
|
17
|
+
- [GitHub CLI](https://cli.github.com/) (`gh`)
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Initial setup (creates GitHub repo, local config, choose a journey)
|
|
23
|
+
stp init
|
|
24
|
+
|
|
25
|
+
# Record a 60-minute walk
|
|
26
|
+
stp add 60
|
|
27
|
+
|
|
28
|
+
# View dashboard in browser
|
|
29
|
+
stp open
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
| Command | Description |
|
|
35
|
+
|---------|-------------|
|
|
36
|
+
| `stp init` | Interactive setup (repo, speed, weight, journey) |
|
|
37
|
+
| `stp add <minutes>` | Record a walk |
|
|
38
|
+
| `stp sync` | Push unpushed commits |
|
|
39
|
+
| `stp open` | Open dashboard locally |
|
|
40
|
+
| `stp open --remote` | Open GitHub Pages (public repos) |
|
|
41
|
+
| `stp status` | Show current status, journey progress, achievements |
|
|
42
|
+
| `stp config speed <value>` | Update default speed (km/h) |
|
|
43
|
+
| `stp config weight <value>` | Update weight (kg, `none` to clear) |
|
|
44
|
+
| `stp config route` | Change journey route |
|
|
45
|
+
| `stp log` | Show recent records in terminal |
|
|
46
|
+
|
|
47
|
+
### `stp add` options
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
stp add 60 # 60 min at default speed
|
|
51
|
+
stp add 60 --speed 5.0 # 60 min at 5 km/h
|
|
52
|
+
stp add 60 --date 2026-03-01 # Backdate a record
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Journey
|
|
56
|
+
|
|
57
|
+
Choose a route during setup and track your virtual progress as you walk:
|
|
58
|
+
|
|
59
|
+
| Title | Route | Distance | Difficulty |
|
|
60
|
+
|-------|-------|----------|------------|
|
|
61
|
+
| ⛷️ Tour du Mont Blanc | Chamonix → Chamonix | 170 km | ★☆☆☆☆ |
|
|
62
|
+
| 🏯 Tokaido | Tokyo → Osaka | 495 km | ★☆☆☆☆ |
|
|
63
|
+
| 🕊️ Camino de Santiago | Lisbon → Santiago | 620 km | ★★☆☆☆ |
|
|
64
|
+
| 🏛️ Nile Valley | Aswan → Cairo | 1,100 km | ★★☆☆☆ |
|
|
65
|
+
| 🐘 Hannibal's March | Cartagena → Rome | 1,500 km | ★★☆☆☆ |
|
|
66
|
+
| ⛪ Via Francigena | Canterbury → Rome | 1,900 km | ★★★☆☆ |
|
|
67
|
+
| 🏜️ Trans-Saharan | Timbuktu → Marrakech | 2,200 km | ★★★☆☆ |
|
|
68
|
+
| 🏔️ Qhapaq Ñan | Quito → Cusco | 2,500 km | ★★★☆☆ |
|
|
69
|
+
| ⛵ Spice Route | Banda Neira → Singapore | 3,500 km | ★★★☆☆ |
|
|
70
|
+
| 🇺🇸 Route 66 | Chicago → Los Angeles | 3,940 km | ★★★☆☆ |
|
|
71
|
+
| 🐫 Silk Road | Xi'an → Constantinople | 7,000 km | ★★★★☆ |
|
|
72
|
+
| 🌍 Around the World | London → London | 40,075 km | ★★★★★ |
|
|
73
|
+
|
|
74
|
+
Each route has waypoints — you'll be notified when you arrive at each city along the way. Complete a route and choose your next adventure.
|
|
75
|
+
|
|
76
|
+
## Achievements
|
|
77
|
+
|
|
78
|
+
Unlock 49 achievements as you walk:
|
|
79
|
+
|
|
80
|
+
- **Distance**: First Step, Century, Thousand Miles, 10K Club, ...
|
|
81
|
+
- **Route**: Tokaido Master, Pilgrim, Silk Merchant, Globe Trotter, ...
|
|
82
|
+
- **Single Walk**: 5K Walk, Half Marathon, Marathon, Speed Demon, ...
|
|
83
|
+
- **Time & Habit**: Early Bird, Night Owl, Weekend Warrior, Double Up
|
|
84
|
+
- **Walk Count**: Milestones at 5, 10, 50, 100, and every 100 up to 1,000
|
|
85
|
+
|
|
86
|
+
## Data
|
|
87
|
+
|
|
88
|
+
Records are stored in `~/.local/share/step-overflow/<repo>/data/walking.csv`:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
datetime,time_min,speed_kmh,distance_km,weight_kg
|
|
92
|
+
2026-03-06T07:30:00,60,4.0,4.00,70
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Config is stored in `~/.config/step-overflow/config.json`.
|
|
96
|
+
|
|
97
|
+
Paths follow the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/). Override with `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME`.
|
|
98
|
+
|
|
99
|
+
## Dashboard
|
|
100
|
+
|
|
101
|
+
The web dashboard (`stp open`) has three tabs:
|
|
102
|
+
|
|
103
|
+
- **Walking**: Summary cards, daily/cumulative charts, record table
|
|
104
|
+
- **Journey**: Route progress visualization with waypoints
|
|
105
|
+
- **Achievements**: Unlocked/locked achievement grid
|
|
106
|
+
|
|
107
|
+
Calories are calculated using METs (Metabolic Equivalent of Task) based on walking speed.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { initCommand } from "./commands/init.js";
|
|
5
|
+
import { addCommand } from "./commands/add.js";
|
|
6
|
+
import { syncCommand } from "./commands/sync.js";
|
|
7
|
+
import { openCommand } from "./commands/open.js";
|
|
8
|
+
import { statusCommand } from "./commands/status.js";
|
|
9
|
+
import { configCommand } from "./commands/config.js";
|
|
10
|
+
import { logCommand } from "./commands/log.js";
|
|
11
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
12
|
+
const main = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: "stp",
|
|
15
|
+
version,
|
|
16
|
+
description: "CLI walking log tool",
|
|
17
|
+
},
|
|
18
|
+
subCommands: {
|
|
19
|
+
init: initCommand,
|
|
20
|
+
add: addCommand,
|
|
21
|
+
sync: syncCommand,
|
|
22
|
+
open: openCommand,
|
|
23
|
+
status: statusCommand,
|
|
24
|
+
config: configCommand,
|
|
25
|
+
log: logCommand,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
runMain(main);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { loadConfig, saveConfig, getCsvPath } from "../lib/config.js";
|
|
6
|
+
import { appendRecord, readRecords } from "../lib/csv.js";
|
|
7
|
+
import { calcCalories } from "../lib/calories.js";
|
|
8
|
+
import { generateIndexHtml } from "../lib/html.js";
|
|
9
|
+
import { gitAdd, gitCommit, gitPush } from "../lib/git.js";
|
|
10
|
+
import { playWalkAnimation } from "../lib/animation.js";
|
|
11
|
+
import { getRoute, getProgress, getNewlyPassedWaypoints, progressBar, formatRouteOption, ROUTES, } from "../lib/routes.js";
|
|
12
|
+
import { checkNewAchievements } from "../lib/achievements.js";
|
|
13
|
+
import { promptSelect } from "../lib/prompt.js";
|
|
14
|
+
export const addCommand = defineCommand({
|
|
15
|
+
meta: { name: "add", description: "Add a walking record" },
|
|
16
|
+
args: {
|
|
17
|
+
time_min: {
|
|
18
|
+
type: "positional",
|
|
19
|
+
description: "Walking time in minutes",
|
|
20
|
+
required: true,
|
|
21
|
+
},
|
|
22
|
+
speed: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Walking speed (km/h)",
|
|
25
|
+
},
|
|
26
|
+
date: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Record date (ISO format or YYYY-MM-DD)",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
run: async ({ args }) => {
|
|
32
|
+
const config = await loadConfig();
|
|
33
|
+
const timeMin = parseFloat(args.time_min);
|
|
34
|
+
if (isNaN(timeMin) || timeMin <= 0) {
|
|
35
|
+
consola.error("Invalid time. Provide a positive number in minutes.");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const speedKmh = args.speed ? parseFloat(args.speed) : config.default_speed;
|
|
39
|
+
if (isNaN(speedKmh) || speedKmh <= 0) {
|
|
40
|
+
consola.error("Invalid speed.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
let datetime;
|
|
44
|
+
if (args.date) {
|
|
45
|
+
const d = new Date(args.date);
|
|
46
|
+
if (isNaN(d.getTime())) {
|
|
47
|
+
consola.error("Invalid date format. Use YYYY-MM-DD or ISO 8601.");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
datetime = d.toISOString().slice(0, 19);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
datetime = new Date().toISOString().slice(0, 19);
|
|
54
|
+
}
|
|
55
|
+
const distanceKm = (timeMin / 60) * speedKmh;
|
|
56
|
+
const csvPath = getCsvPath(config);
|
|
57
|
+
await appendRecord(csvPath, {
|
|
58
|
+
datetime,
|
|
59
|
+
time_min: timeMin,
|
|
60
|
+
speed_kmh: speedKmh,
|
|
61
|
+
distance_km: distanceKm,
|
|
62
|
+
weight_kg: config.weight_kg,
|
|
63
|
+
});
|
|
64
|
+
// Record summary line
|
|
65
|
+
let recordLine = `${distanceKm.toFixed(2)} km | ${timeMin} min | ${speedKmh} km/h`;
|
|
66
|
+
if (config.weight_kg) {
|
|
67
|
+
const cal = calcCalories(speedKmh, timeMin, config.weight_kg);
|
|
68
|
+
recordLine += ` | ${cal} kcal`;
|
|
69
|
+
}
|
|
70
|
+
consola.success(recordLine);
|
|
71
|
+
// --- Compute all state before HTML generation ---
|
|
72
|
+
const records = await readRecords(csvPath);
|
|
73
|
+
const totalKm = records.reduce((s, r) => s + r.distance_km, 0);
|
|
74
|
+
// Journey
|
|
75
|
+
const journey = config.journey;
|
|
76
|
+
const route = journey ? getRoute(journey.route_id) : undefined;
|
|
77
|
+
let justCompleted = false;
|
|
78
|
+
let newWaypoints = [];
|
|
79
|
+
let journeyProgress;
|
|
80
|
+
if (journey && route) {
|
|
81
|
+
const journeyKm = Math.max(0, totalKm - journey.started_km);
|
|
82
|
+
const prevJourneyKm = Math.max(0, journeyKm - distanceKm);
|
|
83
|
+
journeyProgress = getProgress(route, journeyKm);
|
|
84
|
+
newWaypoints = getNewlyPassedWaypoints(route, prevJourneyKm, journeyKm);
|
|
85
|
+
justCompleted = prevJourneyKm < route.total_km && journeyKm >= route.total_km;
|
|
86
|
+
if (justCompleted && !journey.completed_routes.includes(route.id)) {
|
|
87
|
+
journey.completed_routes.push(route.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Achievements
|
|
91
|
+
const unlocked = config.achievements ?? {};
|
|
92
|
+
const completedRoutes = journey?.completed_routes ?? [];
|
|
93
|
+
const ctx = { allRecords: records, totalKm, completedRoutes };
|
|
94
|
+
const newAchievements = checkNewAchievements(ctx, unlocked);
|
|
95
|
+
for (const a of newAchievements) {
|
|
96
|
+
unlocked[a.id] = new Date().toISOString().slice(0, 19);
|
|
97
|
+
}
|
|
98
|
+
// Save config if anything changed
|
|
99
|
+
if (newAchievements.length > 0 || justCompleted) {
|
|
100
|
+
config.achievements = unlocked;
|
|
101
|
+
await saveConfig(config);
|
|
102
|
+
}
|
|
103
|
+
// --- Git sync (with updated config) runs in parallel with animation ---
|
|
104
|
+
const cwd = config.local_path;
|
|
105
|
+
const gitSync = (async () => {
|
|
106
|
+
await writeFile(join(cwd, "docs", "index.html"), generateIndexHtml(config));
|
|
107
|
+
await gitAdd(["data/walking.csv", "docs/index.html"], cwd);
|
|
108
|
+
try {
|
|
109
|
+
await gitCommit(`Walk: ${timeMin} min, ${distanceKm.toFixed(2)} km`, cwd);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "commit-failed";
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await gitPush(cwd);
|
|
116
|
+
return "pushed";
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return "push-failed";
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
await playWalkAnimation(gitSync);
|
|
123
|
+
// --- Display notifications ---
|
|
124
|
+
// Waypoint arrivals
|
|
125
|
+
if (newWaypoints.length > 0 && journeyProgress) {
|
|
126
|
+
consola.log("");
|
|
127
|
+
for (const w of newWaypoints) {
|
|
128
|
+
consola.success(`You've arrived in ${w.name} \u2014 ${w.description}`);
|
|
129
|
+
}
|
|
130
|
+
if (journeyProgress.next) {
|
|
131
|
+
consola.info(`Next stop: ${journeyProgress.next.name} \u2014 ${Math.ceil(journeyProgress.nextDistance)} km to go`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Journey completion or progress
|
|
135
|
+
if (journey && route) {
|
|
136
|
+
if (justCompleted) {
|
|
137
|
+
const startDate = journey.started_at.split("T")[0];
|
|
138
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
139
|
+
const days = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
140
|
+
consola.log("");
|
|
141
|
+
consola.box(`Journey Complete!\n\n` +
|
|
142
|
+
`${route.from} \u2192 ${route.to} (${route.total_km.toLocaleString()} km)\n` +
|
|
143
|
+
`Completed in ${days} days`);
|
|
144
|
+
}
|
|
145
|
+
else if (journeyProgress) {
|
|
146
|
+
consola.log("");
|
|
147
|
+
consola.log(` \u{1F6B6} ${route.from} \u2192 ${route.to} ` +
|
|
148
|
+
`${journeyProgress.percent.toFixed(1)}% (${Math.floor(journeyProgress.walkedKm)} / ${route.total_km.toLocaleString()} km)`);
|
|
149
|
+
consola.log(` ${progressBar(journeyProgress.percent)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Achievements
|
|
153
|
+
if (newAchievements.length > 0) {
|
|
154
|
+
consola.log("");
|
|
155
|
+
for (const a of newAchievements) {
|
|
156
|
+
consola.success(`\u{1F3C5} ${a.name} \u2014 ${a.description}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Next route prompt on completion
|
|
160
|
+
if (justCompleted) {
|
|
161
|
+
consola.log("");
|
|
162
|
+
const routeOptions = ROUTES.map(formatRouteOption);
|
|
163
|
+
const selectedOption = await promptSelect("Choose your next journey:", routeOptions);
|
|
164
|
+
const selectedRoute = ROUTES[routeOptions.indexOf(selectedOption)];
|
|
165
|
+
config.journey = {
|
|
166
|
+
route_id: selectedRoute.id,
|
|
167
|
+
started_at: new Date().toISOString().slice(0, 19),
|
|
168
|
+
started_km: totalKm,
|
|
169
|
+
completed_routes: completedRoutes,
|
|
170
|
+
};
|
|
171
|
+
await saveConfig(config);
|
|
172
|
+
consola.success(`New journey: ${selectedRoute.from} \u2192 ${selectedRoute.to} (${selectedRoute.total_km.toLocaleString()} km)`);
|
|
173
|
+
}
|
|
174
|
+
// Git sync result
|
|
175
|
+
const gitResult = await gitSync;
|
|
176
|
+
switch (gitResult) {
|
|
177
|
+
case "pushed":
|
|
178
|
+
consola.success("Pushed to GitHub.");
|
|
179
|
+
break;
|
|
180
|
+
case "push-failed":
|
|
181
|
+
consola.warn("Push failed. Run `stp sync` to retry.");
|
|
182
|
+
break;
|
|
183
|
+
case "commit-failed":
|
|
184
|
+
consola.warn("Commit failed. Run `stp sync` later.");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { loadConfig, saveConfig, getCsvPath } from "../lib/config.js";
|
|
4
|
+
import { readRecords } from "../lib/csv.js";
|
|
5
|
+
import { ROUTES, formatRouteOption } from "../lib/routes.js";
|
|
6
|
+
import { promptSelect, promptConfirm } from "../lib/prompt.js";
|
|
7
|
+
export const configCommand = defineCommand({
|
|
8
|
+
meta: { name: "config", description: "Update configuration" },
|
|
9
|
+
args: {
|
|
10
|
+
key: {
|
|
11
|
+
type: "positional",
|
|
12
|
+
description: "Config key (speed, weight, route)",
|
|
13
|
+
required: true,
|
|
14
|
+
},
|
|
15
|
+
value: {
|
|
16
|
+
type: "positional",
|
|
17
|
+
description: "New value (not needed for route)",
|
|
18
|
+
required: false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
run: async ({ args }) => {
|
|
22
|
+
const config = await loadConfig();
|
|
23
|
+
switch (args.key) {
|
|
24
|
+
case "speed": {
|
|
25
|
+
if (!args.value) {
|
|
26
|
+
consola.error("Usage: stp config speed <value>");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const speed = parseFloat(args.value);
|
|
30
|
+
if (isNaN(speed) || speed <= 0) {
|
|
31
|
+
consola.error("Invalid speed. Provide a positive number.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
config.default_speed = speed;
|
|
35
|
+
await saveConfig(config);
|
|
36
|
+
consola.success(`Default speed updated to ${speed} km/h`);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "weight": {
|
|
40
|
+
if (!args.value) {
|
|
41
|
+
consola.error("Usage: stp config weight <value|none>");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (args.value === "none" || args.value === "null" || args.value === "0") {
|
|
45
|
+
config.weight_kg = null;
|
|
46
|
+
await saveConfig(config);
|
|
47
|
+
consola.success("Weight cleared.");
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const weight = parseFloat(args.value);
|
|
51
|
+
if (isNaN(weight) || weight <= 0) {
|
|
52
|
+
consola.error("Invalid weight. Provide a positive number, or 'none' to clear.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
config.weight_kg = weight;
|
|
56
|
+
await saveConfig(config);
|
|
57
|
+
consola.success(`Weight updated to ${weight} kg`);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "route": {
|
|
61
|
+
if (config.journey) {
|
|
62
|
+
const currentRoute = ROUTES.find((r) => r.id === config.journey.route_id);
|
|
63
|
+
if (currentRoute) {
|
|
64
|
+
consola.info(`Current route: ${currentRoute.from} \u2192 ${currentRoute.to}`);
|
|
65
|
+
}
|
|
66
|
+
const confirmed = await promptConfirm("Changing route will reset your journey progress. Continue?", false);
|
|
67
|
+
if (!confirmed) {
|
|
68
|
+
consola.info("Cancelled.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const routeOptions = ROUTES.map(formatRouteOption);
|
|
73
|
+
const selectedOption = await promptSelect("Choose your journey:", routeOptions);
|
|
74
|
+
const selectedRoute = ROUTES[routeOptions.indexOf(selectedOption)];
|
|
75
|
+
const records = await readRecords(getCsvPath(config));
|
|
76
|
+
const totalKm = records.reduce((s, r) => s + r.distance_km, 0);
|
|
77
|
+
config.journey = {
|
|
78
|
+
route_id: selectedRoute.id,
|
|
79
|
+
started_at: new Date().toISOString().slice(0, 19),
|
|
80
|
+
started_km: totalKm,
|
|
81
|
+
completed_routes: config.journey?.completed_routes ?? [],
|
|
82
|
+
};
|
|
83
|
+
await saveConfig(config);
|
|
84
|
+
consola.success(`Journey set: ${selectedRoute.from} \u2192 ${selectedRoute.to} (${selectedRoute.total_km.toLocaleString()} km)`);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
consola.error(`Unknown config key: "${args.key}". Available: speed, weight, route`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|