完成了散步功能後,App 已經會保存每一次散步的距離、時間、日期與路線。
歷史紀錄頁可以查看單次散步,例如:
7/20 2.3 km 28 分鐘
7/21 3.1 km 42 分鐘
7/23 1.8 km 24 分鐘
不過歷史紀錄只能告訴我們「某一次散步走了多少」。
如果想知道最近總共走了多少、多久散步一次,或最近有沒有持續維持散步習慣,就需要把這些紀錄整理成統計資料。
今天要完成的,就是這個統計頁。
可以統計的資料很多,例如平均速度、最高速度、累積步數、卡路里等等。
但如果全部放進同一個頁面,最後只會變成一堆數字,反而不容易看出重點。
目前我先保留幾個比較實用的統計:
統計期間則提供:
近 7 天|近 30 天|全部
預設顯示近 7 天。
其中累積次數、散步天數、距離、時間、每日趨勢和星期分布都會跟著統計期間改變。
不過「目前連續天數」和「最長連續天數」比較特別,這兩個數字代表的是完整的散步紀錄,因此不會受到近 7 天或近 30 天的限制。
真正開始計算以前,有幾個地方要先處理好,不然最後顯示出來的數字很容易和實際情況對不上。
假設同一天散步兩次:
7/20 08:00
7/20 20:00
代表的是:
散步次數:2
散步天數:1
所以在計算散步天數或連續天數時,不能直接拿 Walk 的數量來算,而是要先整理出「哪些日期有散步」。

假設近七天只有三天散步:
7/20
7/23
7/25
如果 API 只回傳這三天,折線圖就會直接把三個點連起來,中間沒有散步的日期也會消失。
因此後端需要先建立完整的日期區間,再把每天的散步資料填進去。沒有散步的日期就保留為 0,這樣圖表才能正確顯示哪些天有散步、哪些天中斷了。

資料庫中的時間都是 UTC,例如:
2026-07-20T23:30:00Z
但這個時間換成台灣時間,其實已經是:
2026-07-21 07:30
如果直接按照 UTC 日期來統計,這筆散步就會被算在 7/20,連星期幾和連續天數也可能跟著算錯。
因此只要統計和「日期」有關,就要先把時間轉成使用者所在時區,再進行後續計算。
處理完前面的問題後,就可以開始整理指定期間內的散步紀錄。
累積次數最簡單,就是目前查到的 Walk 數量:
const totalWalks = walks.length;
散步天數則不能直接使用 walks.length,因為同一天可能散步不只一次。
先把每筆 Walk 的時間轉成使用者所在時區,只保留日期:
const walkingDays = new Set(
walks.map((walk) =>
dayjs(walk.startedAt)
.tz(timezone)
.format("YYYY-MM-DD"),
),
).size;
這樣即使同一天有兩筆或更多 Walk,最後都只會算成一天。
累積距離與累積時間,則是把每一筆紀錄的正式統計結果加總:
const totalDistance = walks.reduce(
(total, walk) => total + walk.distanceMeters,
0,
);
const totalDuration = walks.reduce(
(total, walk) => total + walk.movingDurationSeconds,
0,
);
這裡使用的是 movingDurationSeconds,因此不會把中途暫停的時間算進去。
最長距離與最久時間則直接找出最大值:
const longestDistance = walks.reduce(
(max, walk) => Math.max(max, walk.distanceMeters),
0,
);
const longestDuration = walks.reduce(
(max, walk) => Math.max(max, walk.movingDurationSeconds),
0,
);
這幾個數字就能組成統計頁最基本的摘要資訊。
連續天數不能直接看 Walk 有幾筆,而是要看「連續幾天都有散步」。
而且這裡使用的是完整的散步紀錄,不會因為目前選擇近 7 天或近 30 天,就把原本更長的連續紀錄截斷。
第一步先整理所有有散步的日期:
const dates = walks.map((walk) =>
dayjs(walk.startedAt)
.tz(timezone)
.format("YYYY-MM-DD"),
);
const uniqueDates = [...new Set(dates)].sort();
例如原本有:
7/20 08:00
7/20 20:00
7/21 18:00
7/22 19:00
整理之後就會變成:
7/20
7/21
7/22
如果完全沒有散步紀錄,就直接回傳 0:
if (uniqueDates.length === 0) {
return {
currentStreak: 0,
longestStreak: 0,
};
}
接著比較前後兩個日期。
如果剛好相差一天,就代表連續紀錄還沒有中斷:
let current = 1;
let longestStreak = 1;
for (let i = 1; i < uniqueDates.length; i++) {
const diff = dayjs
.tz(uniqueDates[i], timezone)
.diff(
dayjs.tz(uniqueDates[i - 1], timezone),
"day",
);
if (diff === 1) {
current++;
longestStreak = Math.max(longestStreak, current);
} else {
current = 1;
}
}
這樣就可以算出歷史最長連續散步天數。
目前連續天數的算法會再多一個規則。
假設昨天以前已經連續散步三天:
7/17 ✓
7/18 ✓
7/19 ✓
7/20 今天
如果今天只是還沒出門,我不希望早上一打開 App,原本的連續 3 天就直接變成 0。
所以這裡的規則是:
程式可以寫成:
const now = dayjs().tz(timezone);
const today = now.format("YYYY-MM-DD");
const yesterday = now
.subtract(1, "day")
.format("YYYY-MM-DD");
const dateSet = new Set(uniqueDates);
let currentStreak = 0;
let checkDate = now;
if (!dateSet.has(today) && dateSet.has(yesterday)) {
checkDate = checkDate.subtract(1, "day");
}
while (dateSet.has(checkDate.format("YYYY-MM-DD"))) {
currentStreak++;
checkDate = checkDate.subtract(1, "day");
}
這樣今天還沒散步時,昨天以前累積的紀錄仍然會保留;如果今天結束後還是沒有新的散步紀錄,隔天再計算時才會真正中斷。
統計頁除了顯示總數,我還希望可以直接看到最近每天的散步變化。
不過每日趨勢不能只整理有散步的日期。
假設近七天實際資料只有:
7/20 2400 m
7/23 3100 m
最後仍然要回傳完整七天:
7/20 2400
7/21 0
7/22 0
7/23 3100
7/24 0
7/25 0
7/26 0
因此第一步先把統計期間內的所有日期建立起來,數值全部從 0 開始:
const tzStartDate = startDate
.tz(timezone)
.startOf("day");
const tzEndDate = endDate
.tz(timezone)
.startOf("day");
const trend = new Map<
string,
{
distance: number;
duration: number;
count: number;
}
>();
const totalDays = tzEndDate.diff(tzStartDate, "day");
for (let i = 0; i <= totalDays; i++) {
const date = tzStartDate
.add(i, "day")
.format("YYYY-MM-DD");
trend.set(date, {
distance: 0,
duration: 0,
count: 0,
});
}
接著再把每一筆 Walk 放到對應日期:
for (const walk of walks) {
const date = dayjs(walk.startedAt)
.tz(timezone)
.format("YYYY-MM-DD");
const item = trend.get(date);
if (!item) continue;
item.distance += walk.distanceMeters;
item.duration += walk.movingDurationSeconds;
item.count += 1;
}
如果同一天散步兩次,距離和時間就會累加,count 則會變成 2。
最後把 Map 轉成前端需要的陣列:
const dailyTrend = [...trend.entries()].map(
([date, value]) => ({
date,
...value,
}),
);
最後得到的資料大概會是:
[
{
date: "2026-07-20",
distance: 2400,
duration: 1800,
count: 1,
},
{
date: "2026-07-21",
distance: 0,
duration: 0,
count: 0,
},
{
date: "2026-07-22",
distance: 0,
duration: 0,
count: 0,
},
{
date: "2026-07-23",
distance: 3100,
duration: 2400,
count: 1,
},
]
日期在建立 Map 時就已經按照順序放進去了,所以前端拿到資料後不需要再自己補日期,直接拿來畫圖就可以。
除了每天的趨勢,我也想知道自己通常星期幾最常出去散步。
這裡統計的是「散步次數」,所以如果星期一早上和晚上各散步一次,星期一就會算兩次。
先建立週日到週六的統計資料:
const weekdayStats = Array.from(
{ length: 7 },
() => ({
count: 0,
}),
);
接著把每一筆 Walk 轉成使用者所在時區,再取得星期幾:
for (const walk of walks) {
const weekday = dayjs(walk.startedAt)
.tz(timezone)
.day();
weekdayStats[weekday].count++;
}
Day.js 的 .day() 會回傳 0 ~ 6:
0 = 星期日
1 = 星期一
2 = 星期二
3 = 星期三
4 = 星期四
5 = 星期五
6 = 星期六
因此最後可以得到:
[
{ count: 3 }, // 星期日
{ count: 5 }, // 星期一
{ count: 2 }, // 星期二
{ count: 4 }, // 星期三
{ count: 1 }, // 星期四
{ count: 0 }, // 星期五
{ count: 6 }, // 星期六
]
即使星期五完全沒有散步,也還是會保留 count: 0。
這樣前端就可以直接按照陣列順序畫出一週七天的分布,不需要再判斷哪些星期沒有資料。

完成這些整理後,原本一筆一筆獨立的散步紀錄,就能變成比較容易理解的統計資料。
歷史紀錄可以回答「某一次走了多少」,統計頁則可以進一步看出最近走了多少、多久散步一次,以及自己的散步習慣有沒有持續下去。