@siegesailor/terminal-sigma 1.0.0 → 1.2.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.
Files changed (38) hide show
  1. package/README.md +125 -40
  2. package/build/app.js +387 -0
  3. package/build/app.js.map +1 -0
  4. package/build/command-line-interface.js +41 -4
  5. package/build/command-line-interface.js.map +1 -1
  6. package/build/components/character.js +17 -293
  7. package/build/components/character.js.map +1 -1
  8. package/build/components/choice.js +32 -0
  9. package/build/components/choice.js.map +1 -0
  10. package/build/components/entry-form.js +54 -0
  11. package/build/components/entry-form.js.map +1 -0
  12. package/build/components/footer.js +27 -0
  13. package/build/components/footer.js.map +1 -0
  14. package/build/components/logs-view.js +102 -0
  15. package/build/components/logs-view.js.map +1 -0
  16. package/build/components/menu-grid.js +27 -45
  17. package/build/components/menu-grid.js.map +1 -1
  18. package/build/components/today-panel.js +30 -0
  19. package/build/components/today-panel.js.map +1 -0
  20. package/build/content.js +286 -0
  21. package/build/content.js.map +1 -0
  22. package/build/health.js +90 -0
  23. package/build/health.js.map +1 -0
  24. package/build/i18n.js +603 -0
  25. package/build/i18n.js.map +1 -0
  26. package/build/pixel-art.js +359 -0
  27. package/build/pixel-art.js.map +1 -0
  28. package/build/progress.js +72 -34
  29. package/build/progress.js.map +1 -1
  30. package/build/quotes.js +157 -5
  31. package/build/quotes.js.map +1 -1
  32. package/build/theme.js +137 -0
  33. package/build/theme.js.map +1 -0
  34. package/package.json +1 -3
  35. package/build/commands/index.js +0 -274
  36. package/build/commands/index.js.map +0 -1
  37. package/build/components/status-panel.js +0 -19
  38. package/build/components/status-panel.js.map +0 -1
package/README.md CHANGED
@@ -1,34 +1,56 @@
1
1
  # Terminal-Sigma
2
2
 
3
3
  [![NPM version](https://img.shields.io/npm/v/%40siegesailor/terminal-sigma?logo=npm)](https://www.npmjs.com/package/@siegesailor/terminal-sigma)
4
+ [![Desktop app](https://img.shields.io/github/v/release/SiegeSailor/Terminal-Sigma?label=desktop&logo=electron)](https://github.com/SiegeSailor/Terminal-Sigma/releases/latest)
4
5
  [![push: Verify](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/push-verify.yml/badge.svg)](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/push-verify.yml)
5
6
  [![main: Release](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/main-release.yml/badge.svg)](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/main-release.yml)
7
+ [![release: Desktop](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/release-desktop.yml/badge.svg)](https://github.com/SiegeSailor/Terminal-Sigma/actions/workflows/release-desktop.yml)
6
8
 
7
- Terminal-Sigma is a terminal-based application that gives game-style feedback on your self-development journey. Though it is a CLI, it is meant to stay open: it updates in real time and is operated through its terminal UI rather than through commands. It features 5 main components:
9
+ Terminal-Sigma is a terminal-based application that gives game-style feedback on your self-development journey. Though it is a CLI, it is meant to stay open: it fills the terminal, updates in real time, and is operated through menus rather than commands. It speaks English, Traditional Chinese, and Korean, and it features 5 main components:
8
10
 
9
- | Component | What It Does |
10
- | --------------------- | ------------------------------------------------------------------------------------------------------ |
11
- | Character Progression | An animated character that levels up with your experience and unlocks new gear as it does |
12
- | Diet Tracker | Logs meals with optional calories, e.g. `Oatmeal 350`, against a daily goal of 3 meals |
13
- | Everyday Quotes | An inspiring quote that refreshes every 30 minutes, on restart, or on demand |
14
- | Tomato Timer | A Pomodoro timer: 25 minutes of focus, then a 5-minute break, against a daily goal of 4 focus sessions |
15
- | Workout Tracker | Logs workouts in minutes, e.g. `Run 30`, against a daily goal of 30 minutes |
11
+ | Component | What It Does |
12
+ | --------------------- | ------------------------------------------------------------------------------------------------------------- |
13
+ | Character Progression | A hooded pixel-art wanderer that levels up with you, unlocks gear, and acts out what you are doing |
14
+ | Everyday Quotes | An inspiring quote that refreshes every 30 minutes, on restart, or on demand |
15
+ | Health | Your meals and workouts against daily targets worked out from your profile, with advice for the rest of today |
16
+ | Logs | Every focus, meal, and workout you logged, with the latest 4 always on the menu |
17
+ | Tomato Timer | A Pomodoro timer: 25 minutes of focus, then a 5-minute break, against a daily goal of 4 focus sessions |
16
18
 
17
- Underneath, your journey is written to the local file `~/.terminal-sigma/progress.json`. There is no plan to support logging at this point, and we chose JSON as a readable format, so that you can port it easily.
19
+ Underneath, your journey and your settings are written to the local file `~/.terminal-sigma/progress.json`. There is no plan to support logging at this point, and we chose JSON as a readable format, so that you can port it easily.
18
20
 
19
21
  ## Installation
20
22
 
21
- The package is available on NPM. Install it globally to get the command:
23
+ Terminal-Sigma comes as a CLI on NPM and as a desktop app on every GitHub release. Both read and write the same `progress.json`, so you can switch between them.
24
+
25
+ ### CLI
26
+
27
+ Install it globally to get the command:
22
28
 
23
29
  ```shell
24
30
  npm install --global @siegesailor/terminal-sigma
25
31
  ```
26
32
 
27
- ### Prerequisites
33
+ The CLI requires [Node.js](https://nodejs.org/) `>= 22`.
34
+
35
+ ### Desktop
36
+
37
+ Download the file for your system from the [latest release](https://github.com/SiegeSailor/Terminal-Sigma/releases/latest). The desktop app needs no Node.js:
28
38
 
29
- Required software for this module:
39
+ | System | File | First Launch |
40
+ | --------------------- | ------------------------------------------------ | ------------------------------------------------------------------- |
41
+ | Linux | `Terminal-Sigma-<version>-linux-x86_64.AppImage` | Run `chmod +x` on the file, then open it |
42
+ | macOS (Apple Silicon) | `Terminal-Sigma-<version>-mac-arm64.dmg` | Drag the app to Applications, then see the note below |
43
+ | macOS (Intel) | `Terminal-Sigma-<version>-mac-x64.dmg` | Drag the app to Applications, then see the note below |
44
+ | Windows | `Terminal-Sigma-<version>-win-x64.exe` | Choose **More info**, then **Run anyway** in the SmartScreen prompt |
30
45
 
31
- - [Node.js](https://nodejs.org/): `>= 22`
46
+ > [!note]
47
+ > The apps are not signed by Apple or Microsoft yet. If macOS says the app "is damaged and can't be opened", it only means the download is quarantined; clear the flag once:
48
+ >
49
+ > ```shell
50
+ > xattr -dr com.apple.quarantine "/Applications/Terminal Sigma.app"
51
+ > ```
52
+
53
+ The desktop app opens the dashboard in its own window, sends a system notification when a focus or break ends, and uses your profile's name and the default timer options below.
32
54
 
33
55
  ## Usage
34
56
 
@@ -38,60 +60,123 @@ Start the dashboard and leave it open in a terminal tab:
38
60
  siegesailor-terminal-sigma --name "Ken"
39
61
  ```
40
62
 
41
- Every option has a default:
63
+ On the first run, it asks for your language and saves the answer in `progress.json`; change it at any time from **Language** in the menu. Every option has a default:
64
+
65
+ | Option | Default | Description |
66
+ | --------------- | ------------------------------------ | ---------------------------------- |
67
+ | `--break <min>` | `5` | Minutes of each Tomato Timer break |
68
+ | `--focus <min>` | `25` | Minutes of each Tomato Timer focus |
69
+ | `--name <name>` | Your profile's name, or `Rook Sigma` | The name of your character |
70
+
71
+ ### Menu
72
+
73
+ The menu drives everything, and Enter opens an item:
42
74
 
43
- | Option | Default | Description |
44
- | --------------- | ------------ | ---------------------------------- |
45
- | `--break <min>` | `5` | Minutes of each Tomato Timer break |
46
- | `--focus <min>` | `25` | Minutes of each Tomato Timer focus |
47
- | `--name <name>` | `Rook Sigma` | The name of your character |
75
+ | Item | Enter Does |
76
+ | --------------- | -------------------------------------------------------------------------------------------------- |
77
+ | Everyday Quotes | Draws a new quote |
78
+ | Health | Shows today's targets and advice, then logs a meal or a workout |
79
+ | Language | Switches between English, Traditional Chinese, and Korean |
80
+ | Logs | Lists every focus, meal, and workout, newest first, with a filter per kind |
81
+ | Profile | Asks for your name, age, height, weight, gender, work style, and health goal, showing saved values |
82
+ | Theme | Picks the color tone of the dashboard and your character: Dusk, Ember, Frost, Mono, or Moss |
83
+ | Tomato Timer | Starts a focus, or pauses, resumes, or stops the running one |
84
+
85
+ A meal asks for the food, its calories, and its protein; a workout asks for the activity, its minutes, and its intensity, one at a time. A stopped focus earns nothing. While you log, the character eats, runs, lifts, or stretches to match, and it celebrates when you level up.
86
+
87
+ ### Health Targets
88
+
89
+ The Health group works out 3 daily targets from your profile, in metric units, and falls back to 2,000 kcal, 50 g of protein, and 30 minutes of exercise until you fill it in:
90
+
91
+ | Target | How It Is Worked Out |
92
+ | -------- | ------------------------------------------------------------------------------------------------------------------------- |
93
+ | Calories | The Mifflin-St Jeor resting rate, times 1.2 to 1.725 by work style, then 500 less to lose fat or 200 to 300 more to build |
94
+ | Exercise | 30 minutes to stay healthy, 45 to lose fat or build muscle, and 60 to build endurance |
95
+ | Protein | 1.0 to 2.0 g per kg of body weight by goal, plus up to 0.3 g for a physical job, never above 2.2 g |
96
+
97
+ The targets are general guidance for healthy adults, not medical advice.
48
98
 
49
99
  ### Keys
50
100
 
51
- The navigation menu at the bottom drives everything:
101
+ Every view shows its keys at the bottom right:
52
102
 
53
- | Key | Action |
54
- | ------------------------- | ------------------------------------------------------------------------- |
55
- | `1` to `4` | Select a menu item |
56
- | Arrows or `h` `j` `k` `l` | Move through the menu |
57
- | Enter | Start or pause the timer, open the meal or workout entry, or draw a quote |
58
- | Esc | Cancel a meal or workout entry |
59
- | `q` or Ctrl+C | Quit |
103
+ | Key | Action |
104
+ | ------------------------- | --------------------------------------------------------- |
105
+ | `1` to `7` | Jump to a menu item |
106
+ | Arrows or `h` `j` `k` `l` | Move through the menu, a list, or the log filters |
107
+ | Enter | Open the item, choose the option, or go to the next field |
108
+ | Esc | Go back to the menu without logging anything |
109
+ | `q` or Ctrl+C | Quit from the menu |
60
110
 
61
- A meal or workout entry is a name followed by a number: the calories of a meal, which are optional, or the minutes of a workout, which are required. When a focus or break ends, the terminal rings its bell.
111
+ When a focus or break ends, the terminal rings its bell.
62
112
 
63
113
  ### Environment Variables
64
114
 
65
115
  Both are optional:
66
116
 
67
- | Variable | Effect |
68
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
69
- | `API_NINJAS_KEY` | Fetch quotes from [API Ninjas](https://api-ninjas.com/api/quotes) instead of the bundled list, falling back to it on any error |
70
- | `TERMINAL_SIGMA_HOME` | The folder that holds `progress.json`, instead of `~/.terminal-sigma`, e.g. a synced folder |
117
+ | Variable | Effect |
118
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | `API_NINJAS_KEY` | Fetch quotes from [API Ninjas](https://api-ninjas.com/api/quotes) instead of the bundled list, in English only, falling back on any error |
120
+ | `TERMINAL_SIGMA_HOME` | The folder that holds `progress.json`, instead of `~/.terminal-sigma`, e.g. a synced folder |
71
121
 
72
122
  ## Progress File
73
123
 
74
- `progress.json` holds only what you logged. Your experience, level, and daily progress are worked out from it every time, so editing the file by hand can never put them out of sync:
124
+ `progress.json` holds your settings and what you logged. Your experience, level, and daily progress are worked out from it every time, so editing the file by hand can never put them out of sync:
75
125
 
76
126
  ```json
77
127
  {
128
+ "language": "en",
129
+ "theme": "ember",
130
+ "profile": {
131
+ "name": "Ken",
132
+ "age": 30,
133
+ "height": 175,
134
+ "weight": 70,
135
+ "gender": "male",
136
+ "workStyle": "desk",
137
+ "goal": "buildMuscle"
138
+ },
78
139
  "focus": [{ "at": "2026-10-03T09:00:00.000Z", "minutes": 25 }],
79
140
  "meals": [
80
- { "at": "2026-10-03T08:00:00.000Z", "food": "Oatmeal", "calories": 350 }
141
+ {
142
+ "at": "2026-10-03T08:00:00.000Z",
143
+ "food": "Oatmeal",
144
+ "calories": 350,
145
+ "protein": 12
146
+ }
81
147
  ],
82
148
  "workouts": [
83
- { "at": "2026-10-03T07:00:00.000Z", "activity": "Run", "minutes": 30 }
149
+ {
150
+ "at": "2026-10-03T07:00:00.000Z",
151
+ "activity": "running",
152
+ "minutes": 30,
153
+ "intensity": "moderate"
154
+ }
84
155
  ]
85
156
  }
86
157
  ```
87
158
 
159
+ Each setting takes 1 of a fixed set of values:
160
+
161
+ | Field | Values |
162
+ | -------------------- | --------------------------------------------------------------------------- |
163
+ | `language` | `en`, `ko`, or `zh-TW` |
164
+ | `profile.gender` | `female`, `male`, or `other` |
165
+ | `profile.goal` | `buildMuscle`, `endurance`, `loseFat`, or `maintain` |
166
+ | `profile.workStyle` | `active`, `athlete`, `desk`, or `standing` |
167
+ | `theme` | `dusk`, `ember`, `frost`, `mono`, or `moss` |
168
+ | `workouts.activity` | `cycling`, `other`, `running`, `strength`, `swimming`, `walking`, or `yoga` |
169
+ | `workouts.intensity` | `light`, `moderate`, or `vigorous` |
170
+
171
+ Every setting is optional, and files written by version 1 still load as they are.
172
+
88
173
  Every 100 XP is a level, and every level from level 2 to level 9 upgrades 1 piece of your character's gear. Experience is earned as follows:
89
174
 
90
- | Entry | Experience |
91
- | -------------- | --------------- |
92
- | Finished focus | 1 XP per minute |
93
- | Meal | 5 XP |
94
- | Workout | 1 XP per minute |
175
+ | Entry | Experience |
176
+ | -------------- | --------------------------------------------------------------------- |
177
+ | Finished focus | 1 XP per minute |
178
+ | Meal | 5 XP |
179
+ | Workout | Per minute: 1 XP when light, 1.5 XP when moderate, 2 XP when vigorous |
95
180
 
96
181
  > [!important]
97
182
  > If `progress.json` is not valid, Terminal-Sigma refuses to start rather than overwrite it. Fix or move the file, then start again.
package/build/app.js ADDED
@@ -0,0 +1,387 @@
1
+ import { homedir } from "node:os";
2
+ import zod from "zod";
3
+ import React from "react";
4
+ import { ThemeProvider } from "@inkjs/ui";
5
+ import { Box, Text, useApp, useInput, useStdout, useWindowSize } from "ink";
6
+ import Character from "./components/character.js";
7
+ import Choice from "./components/choice.js";
8
+ import EntryForm from "./components/entry-form.js";
9
+ import Footer from "./components/footer.js";
10
+ import LogsView, { LogRow } from "./components/logs-view.js";
11
+ import MenuGrid from "./components/menu-grid.js";
12
+ import TodayPanel from "./components/today-panel.js";
13
+ import { characterWidth, dietFieldsOf, layoutOf, menuItemsOf, menuOrder, profileFieldsOf, sectionsOf, workoutFieldsOf, } from "./content.js";
14
+ import { adviceOf, targetsOf } from "./health.js";
15
+ import { formatNumber, genders, healthGoals, intensities, languages, messagesOf, workoutActivities, workStyles, } from "./i18n.js";
16
+ import { experienceOf, experiencePerMeal, levelOf, logsOf, saveProgress, todayOf, workoutExperience, } from "./progress.js";
17
+ import { fetchQuote, hasQuoteApi, localizedQuote, quoteRefreshMinutes, randomBundledQuote, } from "./quotes.js";
18
+ import { paletteContext, themeNames, themes, uiThemeOf, } from "./theme.js";
19
+ import { advanceTimer, formatClock, idleTimer, remainingOf, toggleTimer, } from "./timer.js";
20
+ export const optionsSchema = zod.object({
21
+ name: zod.string().min(1).optional(),
22
+ focus: zod.number().int().positive().default(25),
23
+ break: zod.number().int().positive().default(5),
24
+ });
25
+ const flashMilliseconds = 6000;
26
+ const recentCount = 4;
27
+ const workoutAnimation = (activity) => {
28
+ if (activity === "strength") {
29
+ return "lift";
30
+ }
31
+ return activity === "yoga" ? "stretch" : "run";
32
+ };
33
+ const languageOptions = languages.map((language) => ({
34
+ label: messagesOf(language).languageName,
35
+ value: language,
36
+ }));
37
+ const pick = (options, value, fallback) => options.find((option) => option === value) ?? fallback;
38
+ function Titled({ title, children, }) {
39
+ return (React.createElement(paletteContext.Consumer, null, (palette) => (React.createElement(Box, { flexDirection: "column" },
40
+ React.createElement(Text, { bold: true, color: palette.accent }, title),
41
+ children))));
42
+ }
43
+ function Swatches({ palette }) {
44
+ return (React.createElement(Text, null, Object.values(palette.metrics).map((color) => (React.createElement(Text, { key: color, color: color }, "\u2588\u2588")))));
45
+ }
46
+ export default function App({ options, file, initialProgress }) {
47
+ const { exit } = useApp();
48
+ const { stdout } = useStdout();
49
+ const size = useWindowSize();
50
+ const [progress, setProgress] = React.useState(initialProgress);
51
+ const [now, setNow] = React.useState(Date.now);
52
+ const durations = React.useMemo(() => ({ focus: options.focus, break: options.break }), [options.focus, options.break]);
53
+ const [timer, setTimer] = React.useState(() => idleTimer(durations));
54
+ const [quote, setQuote] = React.useState(randomBundledQuote);
55
+ const [view, setView] = React.useState("menu");
56
+ const [flash, setFlash] = React.useState();
57
+ const [draftActivity, setDraftActivity] = React.useState();
58
+ const language = progress.language ?? "en";
59
+ const messages = messagesOf(language);
60
+ const themeName = progress.theme ?? "ember";
61
+ const palette = themes[themeName];
62
+ const uiTheme = React.useMemo(() => uiThemeOf(palette), [palette]);
63
+ const name = options.name ?? progress.profile?.name ?? "Rook Sigma";
64
+ const [message, setMessage] = React.useState(() => ({
65
+ text: messages.savedTo(file.replace(homedir(), "~")),
66
+ tone: "info",
67
+ }));
68
+ const say = (text, tone = "info") => {
69
+ setMessage({ text, tone });
70
+ };
71
+ // Saves first and only then shows the change, so the screen never claims
72
+ // something the file does not hold.
73
+ const save = React.useCallback((next) => {
74
+ try {
75
+ saveProgress(file, next);
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ setMessage({
80
+ text: messagesOf(next.language ?? "en").notSaved(String(error)),
81
+ tone: "error",
82
+ });
83
+ return false;
84
+ }
85
+ }, [file]);
86
+ const record = React.useCallback((update, text, activity) => {
87
+ const next = update(progress);
88
+ if (!save(next)) {
89
+ return;
90
+ }
91
+ const after = levelOf(experienceOf(next));
92
+ const leveledUp = after > levelOf(experienceOf(progress));
93
+ setProgress(next);
94
+ setMessage({
95
+ text: leveledUp
96
+ ? `${text} ${messagesOf(next.language ?? "en").form.levelUp(after)}`
97
+ : text,
98
+ tone: "success",
99
+ });
100
+ setFlash({
101
+ activity: leveledUp ? "levelUp" : activity,
102
+ until: Date.now() + flashMilliseconds,
103
+ });
104
+ }, [progress, save]);
105
+ React.useEffect(() => {
106
+ const interval = setInterval(() => {
107
+ setNow(Date.now());
108
+ }, 1000);
109
+ return () => {
110
+ clearInterval(interval);
111
+ };
112
+ }, []);
113
+ React.useEffect(() => {
114
+ const { timer: next, finished } = advanceTimer(timer, now, durations);
115
+ if (!finished) {
116
+ return;
117
+ }
118
+ const { timer: words } = messagesOf(language);
119
+ setTimer(next);
120
+ stdout.write("\u0007");
121
+ if (finished === "break") {
122
+ setMessage({ text: words.breakOver, tone: "info" });
123
+ return;
124
+ }
125
+ record((current) => ({
126
+ ...current,
127
+ focus: [
128
+ ...current.focus,
129
+ { at: new Date(now).toISOString(), minutes: durations.focus },
130
+ ],
131
+ }), words.focusComplete(durations.focus, durations.break), "idle");
132
+ }, [now, timer, durations, record, stdout, language]);
133
+ React.useEffect(() => {
134
+ let isMounted = true;
135
+ const refresh = async () => {
136
+ const next = await fetchQuote();
137
+ if (isMounted) {
138
+ setQuote(next);
139
+ }
140
+ };
141
+ // The bundled quote shown first is already fresh; only the API is worth a call.
142
+ if (hasQuoteApi()) {
143
+ void refresh();
144
+ }
145
+ const interval = setInterval(() => {
146
+ void refresh();
147
+ }, quoteRefreshMinutes * 60_000);
148
+ return () => {
149
+ isMounted = false;
150
+ clearInterval(interval);
151
+ };
152
+ }, []);
153
+ const backToMenu = (text) => {
154
+ setView("menu");
155
+ setDraftActivity(undefined);
156
+ if (text) {
157
+ say(text);
158
+ }
159
+ };
160
+ useInput((input, key) => {
161
+ if (view === "menu" && input === "q") {
162
+ exit();
163
+ }
164
+ else if (view !== "menu" && key.escape) {
165
+ const isForm = ["diet", "workout", "profile"].includes(view);
166
+ backToMenu(isForm ? messages.form.cancelled : undefined);
167
+ }
168
+ }, { isActive: progress.language !== undefined });
169
+ const saveSetting = (next, text) => {
170
+ if (save(next)) {
171
+ setProgress(next);
172
+ backToMenu(text);
173
+ }
174
+ };
175
+ const chooseLanguage = (value) => {
176
+ const next = pick(languages, value, "en");
177
+ saveSetting({ ...progress, language: next }, messagesOf(next).languageChanged);
178
+ };
179
+ const providers = (children) => (React.createElement(paletteContext.Provider, { value: palette },
180
+ React.createElement(ThemeProvider, { theme: uiTheme }, children)));
181
+ if (progress.language === undefined) {
182
+ return providers(React.createElement(Box, { borderColor: palette.accent, borderStyle: "round", flexDirection: "column", paddingX: 2, paddingY: 1 },
183
+ React.createElement(Titled, { title: "\u273B Terminal Sigma" },
184
+ React.createElement(Text, { dimColor: true }, messages.chooseLanguage),
185
+ React.createElement(Box, { marginTop: 1 },
186
+ React.createElement(Choice, { options: languageOptions, onSelect: chooseLanguage })))));
187
+ }
188
+ const isRunning = timer.endsAt !== undefined;
189
+ const isIdle = !isRunning &&
190
+ timer.phase === "focus" &&
191
+ timer.remaining === durations.focus * 60_000;
192
+ const clock = formatClock(remainingOf(timer, now));
193
+ const phaseLabel = messages.timer[timer.phase];
194
+ const selectMenu = (index) => {
195
+ const item = menuOrder[index];
196
+ if (item === "timer" && isIdle) {
197
+ setTimer(toggleTimer(timer, Date.now()));
198
+ say(messages.timer.started);
199
+ }
200
+ else if (item === "quotes") {
201
+ void fetchQuote().then(setQuote);
202
+ say(messages.quoteDrawn);
203
+ }
204
+ else if (item) {
205
+ setView(item);
206
+ }
207
+ };
208
+ const chooseTimerAction = (action) => {
209
+ if (action === "toggle") {
210
+ setTimer(toggleTimer(timer, Date.now()));
211
+ backToMenu(isRunning ? messages.timer.paused : messages.timer.resumed);
212
+ }
213
+ else if (action === "stop") {
214
+ setTimer(idleTimer(durations));
215
+ backToMenu(messages.timer.stopped);
216
+ }
217
+ else {
218
+ backToMenu();
219
+ }
220
+ };
221
+ const chooseHealthAction = (action) => {
222
+ if (action === "diet" || action === "workout" || action === "profile") {
223
+ setView(action);
224
+ }
225
+ else {
226
+ backToMenu();
227
+ }
228
+ };
229
+ const submitMeal = (values) => {
230
+ const food = values.food ?? "";
231
+ backToMenu();
232
+ record((current) => ({
233
+ ...current,
234
+ meals: [
235
+ ...current.meals,
236
+ {
237
+ at: new Date().toISOString(),
238
+ food,
239
+ calories: Number(values.calories),
240
+ protein: Number(values.protein),
241
+ },
242
+ ],
243
+ }), messages.form.mealLogged(food, experiencePerMeal), "eat");
244
+ };
245
+ const submitWorkout = (values) => {
246
+ const workout = {
247
+ at: new Date().toISOString(),
248
+ activity: pick(workoutActivities, values.activity, "other"),
249
+ minutes: Number(values.minutes),
250
+ intensity: pick(intensities, values.intensity, "moderate"),
251
+ };
252
+ backToMenu();
253
+ record((current) => ({ ...current, workouts: [...current.workouts, workout] }), messages.form.workoutLogged(messages.workoutActivity[workout.activity], workout.minutes, workoutExperience(workout)), workoutAnimation(workout.activity));
254
+ };
255
+ const submitProfile = (values) => {
256
+ saveSetting({
257
+ ...progress,
258
+ profile: {
259
+ name: values.name ?? name,
260
+ age: Number(values.age),
261
+ height: Number(values.height),
262
+ weight: Number(values.weight),
263
+ gender: pick(genders, values.gender, "other"),
264
+ workStyle: pick(workStyles, values.workStyle, "desk"),
265
+ goal: pick(healthGoals, values.goal, "maintain"),
266
+ },
267
+ }, messages.form.profileSaved);
268
+ };
269
+ const chooseTheme = (value) => {
270
+ const next = pick(themeNames, value, "ember");
271
+ saveSetting({ ...progress, theme: next }, messages.themes.changed(messages.themes.names[next]));
272
+ };
273
+ const experience = experienceOf(progress);
274
+ const level = levelOf(experience);
275
+ const today = todayOf(progress, now);
276
+ const logs = logsOf(progress);
277
+ const columns = size.columns || 100;
278
+ const rows = size.rows || 40;
279
+ const layout = layoutOf(columns, rows);
280
+ const activity = flash && flash.until > now
281
+ ? flash.activity
282
+ : view === "diet"
283
+ ? "eat"
284
+ : view === "workout"
285
+ ? workoutAnimation(draftActivity)
286
+ : isRunning
287
+ ? { focus: "focus", break: "break" }[timer.phase]
288
+ : "idle";
289
+ let timerSignal = isRunning ? clock : messages.signal.paused;
290
+ if (isIdle) {
291
+ timerSignal = messages.signal.ready;
292
+ }
293
+ const hints = {
294
+ menu: messages.hints.menu,
295
+ logs: messages.hints.logs,
296
+ diet: messages.hints.form,
297
+ workout: messages.hints.form,
298
+ profile: messages.hints.form,
299
+ timer: messages.hints.select,
300
+ health: messages.hints.select,
301
+ theme: messages.hints.select,
302
+ language: messages.hints.select,
303
+ };
304
+ const panels = {
305
+ menu: (React.createElement(Box, { flexDirection: "column" },
306
+ React.createElement(MenuGrid, { columns: layout.menuColumns, isActive: view === "menu", items: menuItemsOf(messages, language, {
307
+ timerSignal,
308
+ progress,
309
+ now,
310
+ logs: logs.length,
311
+ theme: themeName,
312
+ focus: durations.focus,
313
+ rest: durations.break,
314
+ }), onSelect: selectMenu }),
315
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 },
316
+ React.createElement(Text, { bold: true, color: palette.soft }, messages.recent.title),
317
+ logs.length === 0 ? (React.createElement(Text, { dimColor: true }, messages.recent.empty)) : null,
318
+ logs.slice(0, recentCount).map((log) => (React.createElement(LogRow, { key: `${log.kind}-${log.at}`, language: language, log: log, messages: messages })))))),
319
+ timer: (React.createElement(Titled, { title: messages.menu.timer },
320
+ React.createElement(Choice, { options: [
321
+ {
322
+ label: isRunning ? messages.timer.pause : messages.timer.resume,
323
+ value: "toggle",
324
+ },
325
+ { label: messages.timer.stop, value: "stop" },
326
+ { label: messages.timer.back, value: "back" },
327
+ ], onSelect: chooseTimerAction }))),
328
+ health: (React.createElement(HealthPanel, { language: language, messages: messages, palette: palette, progress: progress, today: today, onSelect: chooseHealthAction })),
329
+ diet: (React.createElement(EntryForm, { fields: dietFieldsOf(messages), messages: messages, title: messages.form.dietTitle, onSubmit: submitMeal })),
330
+ workout: (React.createElement(EntryForm, { fields: workoutFieldsOf(messages), messages: messages, title: messages.form.workoutTitle, onSubmit: submitWorkout, onValues: (values) => {
331
+ setDraftActivity(values.activity);
332
+ } })),
333
+ profile: (React.createElement(EntryForm, { fields: profileFieldsOf(messages, progress.profile, name), messages: messages, title: messages.form.profileTitle, onSubmit: submitProfile })),
334
+ theme: (React.createElement(Titled, { title: messages.menu.theme },
335
+ React.createElement(Choice, { initialValue: themeName, options: themeNames.map((option) => ({
336
+ label: messages.themes.names[option].padEnd(8),
337
+ value: option,
338
+ hint: React.createElement(Swatches, { palette: themes[option] }),
339
+ })), onSelect: chooseTheme }))),
340
+ language: (React.createElement(Titled, { title: messages.menu.language },
341
+ React.createElement(Choice, { initialValue: language, options: languageOptions, onSelect: chooseLanguage }))),
342
+ };
343
+ const shownQuote = localizedQuote(quote, language);
344
+ const todayPanel = (React.createElement(TodayPanel, { sections: sectionsOf(language, palette, progress, now), title: messages.today.title, width: layout.todayWidth }));
345
+ return providers(React.createElement(Box, { flexDirection: "column", height: size.rows ? rows : undefined, overflow: "hidden", width: columns },
346
+ React.createElement(Box, { borderColor: palette.accent, borderStyle: "round", flexDirection: "column", flexShrink: 0, paddingX: 1 },
347
+ React.createElement(Box, { justifyContent: "space-between" },
348
+ React.createElement(Text, { bold: true, color: palette.accent }, "\u273B Terminal Sigma"),
349
+ React.createElement(Text, { dimColor: true }, new Date(now).toTimeString().slice(0, 8))),
350
+ React.createElement(Text, { dimColor: true, italic: true, wrap: "truncate-end" }, `“${shownQuote.quote}” — ${shownQuote.author}`)),
351
+ view === "logs" ? (React.createElement(Box, { borderColor: palette.accent, borderStyle: "round", flexGrow: 1, paddingX: 1 },
352
+ React.createElement(LogsView, { height: rows - 7, language: language, logs: logs, messages: messages }))) : (React.createElement(React.Fragment, null,
353
+ React.createElement(Box, { flexDirection: layout.stacked ? "column-reverse" : "row", flexGrow: 1 },
354
+ layout.showCharacter ? (React.createElement(Character, { activity: activity, label: activity === "levelUp"
355
+ ? `${messages.activity.levelUp} ${messages.level(level)}`
356
+ : messages.activity[activity], level: messages.level(level), levelNumber: level, name: name, width: characterWidth })) : null,
357
+ layout.showToday && layout.todayBeside ? todayPanel : null,
358
+ React.createElement(Box, { borderColor: view === "menu" ? palette.border : palette.accent, borderStyle: "round", flexDirection: "column", paddingX: 1, width: layout.panelWidth }, view === "menu" ? panels.menu : panels[view])),
359
+ layout.showToday && !layout.todayBeside ? todayPanel : null)),
360
+ React.createElement(Footer, { hint: hints[view], message: isIdle || isRunning
361
+ ? message.text
362
+ : `${messages.timer.pausedAt(phaseLabel, clock)} · ${message.text}`, status: isRunning ? messages.timer.left(phaseLabel, clock) : undefined, tone: message.tone })));
363
+ }
364
+ function HealthPanel({ language, messages, palette, progress, today, onSelect, }) {
365
+ const { profile } = progress;
366
+ const targets = targetsOf(profile);
367
+ const { health } = messages;
368
+ const options = [
369
+ ...(profile ? [] : [{ label: health.setUp, value: "profile" }]),
370
+ { label: health.logMeal, value: "diet" },
371
+ { label: health.logWorkout, value: "workout" },
372
+ { label: health.back, value: "back" },
373
+ ];
374
+ return (React.createElement(Box, { flexDirection: "column" },
375
+ React.createElement(Text, { bold: true, color: palette.accent }, profile
376
+ ? health.title(messages.goal[profile.goal])
377
+ : messages.menu.health),
378
+ React.createElement(Text, { dimColor: true }, health.targets(formatNumber(language, targets.calories), targets.protein, targets.workoutMinutes)),
379
+ React.createElement(Box, { flexDirection: "column", marginY: 1 },
380
+ profile ? null : React.createElement(Text, { dimColor: true }, health.noProfile),
381
+ adviceOf(profile, today, language, messages).map((advice) => (React.createElement(Text, { key: advice.text },
382
+ React.createElement(Text, { color: advice.isDone ? palette.success : palette.accent }, "⏺ "),
383
+ React.createElement(Text, null, advice.text)))),
384
+ profile ? (React.createElement(Text, { dimColor: true, italic: true }, health.tip[profile.goal])) : null),
385
+ React.createElement(Choice, { options: options, onSelect: onSelect })));
386
+ }
387
+ //# sourceMappingURL=app.js.map