diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 3e5f53d..0000000 --- a/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: "ts-jest", - testEnvironment: "node", - moduleNameMapper: { - "decky-frontend-lib": "decky-frontend-lib", - }, - transformIgnorePatterns: ["/node_modules/(?!decky-frontend-lib/)"], -}; diff --git a/package.json b/package.json index 7082f22..b2d7c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "PlayTime", - "version": "2.0.7", + "version": "2.0.8", "description": "PlayTime", "type": "module", "scripts": { diff --git a/src/app/formatters.ts b/src/app/formatters.ts index 6ccedc0..aabf3a9 100644 --- a/src/app/formatters.ts +++ b/src/app/formatters.ts @@ -3,63 +3,123 @@ import { type Duration, intervalToDuration } from "date-fns"; import type { Interval } from "./reports"; export { - humanReadableTime, - toIsoDateOnly, formatMonthInterval, formatWeekInterval, + getDurationInDays, + getDurationInHours, + humanReadableTime, + toIsoDateOnly, }; -function humanReadableTime( - seconds: number, +function reduceDuration( + accumulator: Array, + key: string, + duration: Duration, + withSeconds: boolean, + short: boolean, +) { + if ( + key === "seconds" && + !withSeconds && + // NOTE(ynhhoJ): If `seconds` key is only one in object then we should display it + Object.keys(duration).length !== 1 + ) { + return accumulator; + } + + const durationValue = duration[key as keyof Duration]; + + if (isNil(durationValue)) { + return accumulator; + } + + if (short) { + accumulator.push(`${durationValue}${key[0]}`); + + return accumulator; + } + + const durationTime = durationValue === 1 ? key.slice(0, key.length - 1) : key; + + accumulator.push(`${durationValue} ${durationTime}`); + + return accumulator; +} + +function getDurationInDays( + durationInSeconds: number, short = true, withSeconds = false, ): string { let duration: Duration = {}; - if (seconds === 0) { + if (durationInSeconds === 0) { duration = { seconds: 0, }; } else { duration = intervalToDuration({ start: new Date(), - end: new Date().getTime() + seconds * 1000, + end: new Date().getTime() + durationInSeconds * 1000, }); } return Object.keys(duration) - .reduce>((accumulator, key) => { - if ( - key === "seconds" && - !withSeconds && - // NOTE(ynhhoJ): If `seconds` key is only one in object then we should display it - Object.keys(duration).length !== 1 - ) { - return accumulator; - } + .reduce>( + (accumulator, key) => + reduceDuration(accumulator, key, duration, withSeconds, short), + [], + ) + .join(" "); +} - const durationValue = duration[key as keyof Duration]; +function getDurationInHours( + durationInSeconds: number, + short = true, + withSeconds = false, +): string { + const duration: Duration = {}; - if (isNil(durationValue)) { - return accumulator; - } + const hours = Math.floor(durationInSeconds / 3600); - if (short) { - accumulator.push(`${durationValue}${key[0]}`); + if (hours !== 0) { + duration.hours = hours; + } + + const minutes = Math.floor((durationInSeconds % 3600) / 60); - return accumulator; - } + if (minutes !== 0) { + duration.minutes = minutes; + } - const durationTime = - durationValue === 1 ? key.slice(0, key.length - 1) : key; + const seconds = Math.floor((durationInSeconds % 3600) % 60); - accumulator.push(`${durationValue} ${durationTime}`); + if ((withSeconds && seconds !== 0) || Object.keys(duration).length === 0) { + duration.seconds = seconds; + } - return accumulator; - }, []) + return Object.keys(duration) + .reduce>( + (accumulator, key) => + reduceDuration(accumulator, key, duration, withSeconds, short), + [], + ) .join(" "); } +function humanReadableTime( + showTimeInHours: boolean, + durationInSeconds: number, + short = true, + withSeconds = false, +) { + if (showTimeInHours) { + return getDurationInHours(durationInSeconds, short, withSeconds); + } + + return getDurationInDays(durationInSeconds, short, withSeconds); +} + function toIsoDateOnly(date: Date) { return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; } diff --git a/src/app/settings.ts b/src/app/settings.ts index 94095a3..b7e2084 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -4,6 +4,14 @@ import logger from "../utils"; export interface PlayTimeSettings { gameChartStyle: ChartStyle; reminderToTakeBreaksInterval: number; + displayTime: { + showSeconds: boolean; + /** + * When `false` time will be shown as `2d 2h` + * when `true` time will be shown as `50h` (`48h` + `2h`) + */ + showTimeInHours: boolean; + }; } export enum ChartStyle { @@ -12,36 +20,84 @@ export enum ChartStyle { } const PLAY_TIME_SETTINGS_KEY = "decky-loader-SDH-Playtime"; + export const DEFAULTS: PlayTimeSettings = { gameChartStyle: ChartStyle.BAR, reminderToTakeBreaksInterval: -1, + displayTime: { + showTimeInHours: true, + showSeconds: false, + }, }; export class Settings { constructor() { - SteamClient.Storage.GetJSON(PLAY_TIME_SETTINGS_KEY).catch((e: Error) => { - if (e.message === "Not found") { - logger.error("Unable to get settings, saving defaults", e); - SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, DEFAULTS); - } else { + SteamClient.Storage.GetJSON(PLAY_TIME_SETTINGS_KEY) + .then(async (json) => { + const parsedJson = JSON.parse(json) as PlayTimeSettings; + + this.setDefaultDisplayTimeIfNeeded(parsedJson); + }) + .catch((e: Error) => { + if (e.message === "Not found") { + logger.error("Unable to get settings, saving defaults", e); + + SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, DEFAULTS); + + return; + } + logger.error("Unable to get settings", e); - } - }); + }); } async get(): Promise { - const settings = await SteamClient.Storage.GetJSON( - PLAY_TIME_SETTINGS_KEY, - ); + const settings = await SteamClient.Storage.GetJSON(PLAY_TIME_SETTINGS_KEY); if (isNil(settings)) { return DEFAULTS; } - return JSON.parse(settings); + let data = JSON.parse(settings); + + data = { + ...data, + displayTime: { + showTimeInHours: !!data.displayTime.showTimeInHours, + showSeconds: !!data.displayTime.showSeconds, + }, + }; + + return data; } async save(data: PlayTimeSettings) { - await SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, data); + await SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, { + ...data, + displayTime: { + showTimeInHours: +data.displayTime.showTimeInHours, + showSeconds: +data.displayTime.showSeconds, + }, + }); + } + + private async setDefaultDisplayTimeIfNeeded(settings: PlayTimeSettings) { + // NOTE(ynhhoJ): If fore some reason `settings` is `null` or `undefined` we should set it + if (isNil(settings)) { + SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, DEFAULTS); + + return; + } + + const { displayTime } = settings; + + if (!isNil(displayTime)) { + return; + } + + await SteamClient.Storage.SetObject(PLAY_TIME_SETTINGS_KEY, { + ...settings, + displayTime: DEFAULTS.displayTime, + }); } } diff --git a/src/components/Timebar.tsx b/src/components/Timebar.tsx index e74cf79..dacf5d1 100644 --- a/src/components/Timebar.tsx +++ b/src/components/Timebar.tsx @@ -1,8 +1,11 @@ +import { useLocator } from "@src/locator"; import { humanReadableTime } from "../app/formatters"; import { TimeBarCSS } from "../styles"; import { VerticalContainer } from "./VerticalContainer"; export const Timebar: React.FC<{ time: number; allTime: number }> = (props) => { + const { currentSettings: settings } = useLocator(); + const barWidth = props.allTime !== 0 ? `${(props.time / props.allTime) * 100}%` : "0%"; @@ -17,7 +20,12 @@ export const Timebar: React.FC<{ time: number; allTime: number }> = (props) => { />
- {humanReadableTime(props.time, true)} + {humanReadableTime( + settings.displayTime.showTimeInHours, + props.time, + true, + settings.displayTime.showSeconds, + )}
); diff --git a/src/components/statistics/AverageAndOverall.tsx b/src/components/statistics/AverageAndOverall.tsx index 4ea9d64..25323ee 100644 --- a/src/components/statistics/AverageAndOverall.tsx +++ b/src/components/statistics/AverageAndOverall.tsx @@ -1,4 +1,5 @@ import { Field, PanelSection, PanelSectionRow } from "@decky/ui"; +import { useLocator } from "@src/locator"; import moment from "moment"; import type { FC } from "react"; import { humanReadableTime } from "../../app/formatters"; @@ -8,6 +9,7 @@ import { FocusableExt } from "../FocusableExt"; export const AverageAndOverall: FC<{ statistics: DailyStatistics[] }> = ( props, ) => { + const { currentSettings: settings } = useLocator(); const overall = props.statistics .map((it) => it.total) .reduce((a, c) => a + c, 0); @@ -22,13 +24,23 @@ export const AverageAndOverall: FC<{ statistics: DailyStatistics[] }> = ( - {humanReadableTime(average)} + {humanReadableTime( + settings.displayTime.showTimeInHours, + average, + true, + settings.displayTime.showSeconds, + )} - {humanReadableTime(overall)} + {humanReadableTime( + settings.displayTime.showTimeInHours, + overall, + true, + settings.displayTime.showSeconds, + )} diff --git a/src/components/statistics/MonthView.tsx b/src/components/statistics/MonthView.tsx index e7bd36a..200892f 100644 --- a/src/components/statistics/MonthView.tsx +++ b/src/components/statistics/MonthView.tsx @@ -1,3 +1,4 @@ +import { useLocator } from "@src/locator"; import moment from "moment"; import type { FC } from "react"; import { @@ -18,6 +19,7 @@ interface DayTime { } export const MonthView: FC<{ statistics: DailyStatistics[] }> = (props) => { + const { currentSettings: settings } = useLocator(); const dayTimes = props.statistics.map((it) => { return { time: it.total, @@ -57,7 +59,13 @@ export const MonthView: FC<{ statistics: DailyStatistics[] }> = (props) => { dataMax * 1.05]} - tickFormatter={(e: number) => humanReadableTime(e, true)} + tickFormatter={(e: number) => + humanReadableTime( + settings.displayTime.showTimeInHours, + e, + true, + ) + } width={75} /> diff --git a/src/containers/CurrentPlayTime.tsx b/src/containers/CurrentPlayTime.tsx index 4a74516..6c34116 100644 --- a/src/containers/CurrentPlayTime.tsx +++ b/src/containers/CurrentPlayTime.tsx @@ -6,8 +6,11 @@ import { useLocator } from "../locator"; function PlaySessionsInformation({ currentPlayTime, }: { currentPlayTime: Array }) { + const { currentSettings: settings } = useLocator(); + if (currentPlayTime.length === 1) { const currentSessionTimeAsText = humanReadableTime( + settings.displayTime.showTimeInHours, currentPlayTime[0].playTime, true, true, @@ -20,7 +23,13 @@ function PlaySessionsInformation({
{currentPlayTime.map((game) => ( - {game.gameName} - {humanReadableTime(game.playTime, true, true)} + {game.gameName} -{" "} + {humanReadableTime( + settings.displayTime.showTimeInHours, + game.playTime, + true, + true, + )} ))}
diff --git a/src/index.tsx b/src/index.tsx index f3738fb..64daccf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { definePlugin, staticClasses } from "@decky/ui"; import { FaClock } from "react-icons/fa"; import { SessionPlayTime } from "./app/SessionPlayTime"; import { Backend } from "./app/backend"; -import { humanReadableTime } from "./app/formatters"; +import { getDurationInHours } from "./app/formatters"; import { SteamEventMiddleware } from "./app/middleware"; import { BreaksReminder } from "./app/notification"; import { Reports } from "./app/reports"; @@ -93,14 +93,15 @@ function createMountables( backend, eventBus, ); + eventBus.addSubscriber((event) => { switch (event.type) { case "NotifyToTakeBreak": toaster.toast({ body: (
- You already playing for {humanReadableTime(event.playTimeSeconds)} - , + You already playing for{" "} + {getDurationInHours(event.playTimeSeconds)},
), title: "PlayTime: remember to take a breaks", diff --git a/src/locator.tsx b/src/locator.tsx index 91637a4..74514d7 100644 --- a/src/locator.tsx +++ b/src/locator.tsx @@ -42,8 +42,10 @@ export const LocatorProvider: React.FC< export const useLocator = () => { const locator = useContext(LocatorContext); + if (!locator) { throw new Error("Locator not found"); } + return locator; }; diff --git a/src/pages/ManuallyAdjustTimePage.tsx b/src/pages/ManuallyAdjustTimePage.tsx index a80f916..88b6ec3 100644 --- a/src/pages/ManuallyAdjustTimePage.tsx +++ b/src/pages/ManuallyAdjustTimePage.tsx @@ -6,7 +6,7 @@ import { PanelSection, TextField, } from "@decky/ui"; -import { type VFC, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import type { DeepNonNullable } from "ts-essentials"; import { humanReadableTime } from "../app/formatters"; import type { GameWithTime } from "../app/model"; @@ -23,8 +23,9 @@ interface TableRowsProps { desiredHours: number | undefined; } -export const ManuallyAdjustTimePage: VFC = () => { - const { timeManipulation: timeMigration } = useLocator(); +export const ManuallyAdjustTimePage = () => { + const { timeManipulation: timeMigration, currentSettings: settings } = + useLocator(); const [isLoading, setLoading] = useState(true); const [gameWithTimeByAppId, setGameWithTimeByAppId] = useState< Map @@ -145,7 +146,14 @@ export const ManuallyAdjustTimePage: VFC = () => { />
- {map(row.playTimeTrackedSec, (it) => humanReadableTime(it))} + {map(row.playTimeTrackedSec, (it) => + humanReadableTime( + settings.displayTime.showTimeInHours, + it, + true, + settings.displayTime.showSeconds, + ), + )}
{ +export const GeneralSettings = () => { const { settings } = useLocator(); const [current, setCurrent] = useState(DEFAULTS); const [loaded, setLoaded] = useState(false); const loadSettings = () => { setLoaded(false); + settings.get().then((r) => { setCurrent(r); setLoaded(true); @@ -59,8 +60,49 @@ export const GeneralSettings: VFC = () => { }} /> + + + { + current.displayTime.showTimeInHours = v.data; + updateSettings(); + }} + /> + + + + { + current.displayTime.showSeconds = v.data; + updateSettings(); + }} + /> + + @@ -87,7 +129,7 @@ export const GeneralSettings: VFC = () => { ); }; -export const TimeManipulation: VFC = () => { +export const TimeManipulation = () => { return (
@@ -101,7 +143,7 @@ export const TimeManipulation: VFC = () => { ); }; -export const SettingsPage: VFC = () => { +export const SettingsPage = () => { return ( (key: string): Promise; + GetJSON(key: string): Promise; SetObject(key: string, value: unknown): T; }; } diff --git a/test/formatters.spec.ts b/test/formatters.spec.ts index 13d605a..e916e5e 100644 --- a/test/formatters.spec.ts +++ b/test/formatters.spec.ts @@ -1,56 +1,146 @@ import { describe, expect, test } from "bun:test"; import { humanReadableTime } from "../src/app/formatters"; -describe("Should present play time as correct human readable text", () => { +describe("Should present play time as correct human readable text with time displayed using HOURS", () => { test("when zero seconds with correct plural", () => { - expect(humanReadableTime(0, false, true)).toBe("0 seconds"); + expect(humanReadableTime(true, 0, false, true)).toBe("0 seconds"); }); test("when 1 second with correct plural", () => { - expect(humanReadableTime(1, false, true)).toBe("1 second"); + expect(humanReadableTime(true, 1, false, true)).toBe("1 second"); }); test("when 34 seconds with correct plural and disabled detailed information", () => { - expect(humanReadableTime(34, false, false)).toBe("34 seconds"); + expect(humanReadableTime(true, 34, false, false)).toBe("34 seconds"); }); test("when 47 seconds, short with correct plural and disabled detailed information", () => { - expect(humanReadableTime(47, true, false)).toBe("47s"); + expect(humanReadableTime(true, 47, true, false)).toBe("47s"); }); test("when 59 seconds with correct plural", () => { - expect(humanReadableTime(59, false, true)).toBe("59 seconds"); + expect(humanReadableTime(true, 59, false, true)).toBe("59 seconds"); }); test("when minute without plural", () => { - expect(humanReadableTime(60 * 1, false)).toBe("1 minute"); + expect(humanReadableTime(true, 60 * 1, false)).toBe("1 minute"); }); test("when 5 minutes with plural", () => { - expect(humanReadableTime(60 * 5, false)).toBe("5 minutes"); + expect(humanReadableTime(true, 60 * 5, false)).toBe("5 minutes"); }); test("when 5 minutes 15 seconds with correct plural", () => { - expect(humanReadableTime(63 * 5, false, true)).toBe("5 minutes 15 seconds"); + expect(humanReadableTime(true, 63 * 5, false, true)).toBe( + "5 minutes 15 seconds", + ); }); test("when we have single hour and plural minutes", () => { - expect(humanReadableTime(60 * 90, false)).toBe("1 hour 30 minutes"); + expect(humanReadableTime(true, 60 * 90, false)).toBe("1 hour 30 minutes"); }); test("when requested short version for hour and minutes", () => { - expect(humanReadableTime(60 * 90, true)).toBe("1h 30m"); + expect(humanReadableTime(true, 60 * 90, true)).toBe("1h 30m"); }); test("when requested short version for minutes", () => { - expect(humanReadableTime(60 * 5, true)).toBe("5m"); + expect(humanReadableTime(true, 60 * 5, true)).toBe("5m"); }); test("when 5 minutes 15 seconds short version", () => { - expect(humanReadableTime(63 * 5, true, true)).toBe("5m 15s"); + expect(humanReadableTime(true, 63 * 5, true, true)).toBe("5m 15s"); }); test("when 5 minutes 15 seconds short version and detailed information is disabled", () => { - expect(humanReadableTime(63 * 5, true, false)).toBe("5m"); + expect(humanReadableTime(true, 63 * 5, true, false)).toBe("5m"); + }); + + test("when 30 hours, short with no seconds", () => { + expect(humanReadableTime(true, 30 * 60 * 60 + 150)).toBe("30h 2m"); + }); + + test("when 30 hours, short with seconds", () => { + expect(humanReadableTime(true, 30 * 60 * 60 + 150, true, true)).toBe( + "30h 2m 30s", + ); + }); + + test("when 30 hours, short with seconds", () => { + expect(humanReadableTime(true, 30 * 60 * 60 + 150, false, true)).toBe( + "30 hours 2 minutes 30 seconds", + ); + }); +}); + +describe("Should present play time as correct human readable text with time displayed using DAYS", () => { + test("when zero seconds with correct plural", () => { + expect(humanReadableTime(false, 0, false, true)).toBe("0 seconds"); + }); + + test("when 1 second with correct plural", () => { + expect(humanReadableTime(false, 1, false, true)).toBe("1 second"); + }); + + test("when 34 seconds with correct plural and disabled detailed information", () => { + expect(humanReadableTime(false, 34, false, false)).toBe("34 seconds"); + }); + + test("when 47 seconds, short with correct plural and disabled detailed information", () => { + expect(humanReadableTime(false, 47, true, false)).toBe("47s"); + }); + + test("when 59 seconds with correct plural", () => { + expect(humanReadableTime(false, 59, false, true)).toBe("59 seconds"); + }); + + test("when minute without plural", () => { + expect(humanReadableTime(false, 60 * 1, false)).toBe("1 minute"); + }); + + test("when 5 minutes with plural", () => { + expect(humanReadableTime(false, 60 * 5, false)).toBe("5 minutes"); + }); + + test("when 5 minutes 15 seconds with correct plural", () => { + expect(humanReadableTime(false, 63 * 5, false, true)).toBe( + "5 minutes 15 seconds", + ); + }); + + test("when we have single hour and plural minutes", () => { + expect(humanReadableTime(false, 60 * 90, false)).toBe("1 hour 30 minutes"); + }); + + test("when requested short version for hour and minutes", () => { + expect(humanReadableTime(false, 60 * 90, true)).toBe("1h 30m"); + }); + + test("when requested short version for minutes", () => { + expect(humanReadableTime(false, 60 * 5, true)).toBe("5m"); + }); + + test("when 5 minutes 15 seconds short version", () => { + expect(humanReadableTime(false, 63 * 5, true, true)).toBe("5m 15s"); + }); + + test("when 5 minutes 15 seconds short version and detailed information is disabled", () => { + expect(humanReadableTime(false, 63 * 5, true, false)).toBe("5m"); + }); + + test("when 30 hours, short, with no seconds", () => { + expect(humanReadableTime(false, 30 * 60 * 60 + 150)).toBe("1d 6h 2m"); + }); + + test("when 30 hours, short with seconds", () => { + expect(humanReadableTime(false, 30 * 60 * 60 + 150, true, true)).toBe( + "1d 6h 2m 30s", + ); + }); + + test("when 30 hours, long, with seconds", () => { + expect(humanReadableTime(false, 30 * 60 * 60 + 150, false, true)).toBe( + "1 day 6 hours 2 minutes 30 seconds", + ); }); });