iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 17

累積散步紀錄,完成散步統計頁

  • 分享至 

  • xImage
  •  

完成了散步功能後,App 已經會保存每一次散步的距離、時間、日期與路線。

歷史紀錄頁可以查看單次散步,例如:

7/20  2.3 km  28 分鐘
7/21  3.1 km  42 分鐘
7/23  1.8 km  24 分鐘

不過歷史紀錄只能告訴我們「某一次散步走了多少」。

如果想知道最近總共走了多少、多久散步一次,或最近有沒有持續維持散步習慣,就需要把這些紀錄整理成統計資料。

今天要完成的,就是這個統計頁。

統計頁要顯示什麼?

可以統計的資料很多,例如平均速度、最高速度、累積步數、卡路里等等。

但如果全部放進同一個頁面,最後只會變成一堆數字,反而不容易看出重點。

目前我先保留幾個比較實用的統計:

  • 累積次數
  • 散步天數
  • 累積距離
  • 累積時間
  • 最長距離
  • 最久時間
  • 目前連續天數
  • 最長連續天數
  • 每日散步趨勢
  • 星期分布

統計期間則提供:

近 7 天|近 30 天|全部

預設顯示近 7 天。

其中累積次數、散步天數、距離、時間、每日趨勢和星期分布都會跟著統計期間改變。

不過「目前連續天數」和「最長連續天數」比較特別,這兩個數字代表的是完整的散步紀錄,因此不會受到近 7 天或近 30 天的限制。

開始統計前,先處理三個問題

真正開始計算以前,有幾個地方要先處理好,不然最後顯示出來的數字很容易和實際情況對不上。

1. 散步次數不等於散步天數

假設同一天散步兩次:

7/20 08:00
7/20 20:00

代表的是:

散步次數:2
散步天數:1

所以在計算散步天數或連續天數時,不能直接拿 Walk 的數量來算,而是要先整理出「哪些日期有散步」。

2. 每日趨勢不能只回傳有散步的日期

假設近七天只有三天散步:

7/20
7/23
7/25

如果 API 只回傳這三天,折線圖就會直接把三個點連起來,中間沒有散步的日期也會消失。

因此後端需要先建立完整的日期區間,再把每天的散步資料填進去。沒有散步的日期就保留為 0,這樣圖表才能正確顯示哪些天有散步、哪些天中斷了。

3. 日期統計要先處理時區

資料庫中的時間都是 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。

所以這裡的規則是:

  • 今天有散步,就從今天開始往前算。
  • 今天還沒散步,但昨天有散步,就從昨天開始往前算。
  • 今天和昨天都沒有散步,連續紀錄才會是 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

這樣前端就可以直接按照陣列順序畫出一週七天的分布,不需要再判斷哪些星期沒有資料。

完成這些整理後,原本一筆一筆獨立的散步紀錄,就能變成比較容易理解的統計資料。

歷史紀錄可以回答「某一次走了多少」,統計頁則可以進一步看出最近走了多少、多久散步一次,以及自己的散步習慣有沒有持續下去。


上一篇
根據散步習慣,自動決定提醒時間
下一篇
設計一套可以持續增加的散步成就系統
系列文
30 天開發一款真正能每天使用的散步 App19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言