---
title: "Keep time"
audience: "anyone whose terminal tool needs a clock or an exit code"
description: "You'll time a task with a clock a mount effect starts, quit with an exit code a script can test and lay the panes out again when the terminal is resized."
snippet_caption: "app/routes/page.tsx"
snippet: "useEffect(() => {\n  const tick = setInterval(() => setNow((n) => n + 1), 1000);\n  return () => clearInterval(tick);\n}, []);"
---

import { Install } from "@src/ui/Install";

You'll time a task with a clock a mount effect starts, quit with an exit code a script can test and lay the panes out again when the terminal is resized. It starts from the app you built in [660](https://snapfirers.com/fsr/learn/660-run-git-and-show-its-output).

<Install captured="0.x" />

## Timers in an effect

An effect with an empty dependency list is a mount effect: it runs once, after the first render. A `setInterval` it starts steps the page each time it is due. The callback runs like a handler, so it may call setters. The function the effect returns is its cleanup. `clearInterval` in it stops the timer when the component goes away.

The page keeps one clock for the whole session. Timing a task records the clock's reading when it starts and subtracts it when it stops.

## Add the state

Add `useApp` and `useStdout` to the `ink` import:

```tsx title="app/routes/page.tsx"
import { Box, Spacer, Text, useApp, useFocus, useFocusManager, useInput, useStdout } from "ink";
```

A task now remembers how long was spent on it:

```tsx title="app/routes/page.tsx"
type Task = { title: string; done: boolean; spent?: number };
```

Add a helper below `save` that prints seconds as minutes and seconds:

```tsx title="app/routes/page.tsx"
function clock(seconds: number) {
  const minutes = Math.floor(seconds / 60);
  return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
}
```

In `Tasks`, below `runs`, add the clock, the task being timed, the terminal's width and `exit`:

```tsx title="app/routes/page.tsx"
const [now, setNow] = useState(0);
const [timing, setTiming] = useState<number | null>(null);
const [startedAt, setStartedAt] = useState(0);
const { stdout } = useStdout();
const [columns, setColumns] = useState(stdout.columns);
const { exit } = useApp();
```

Below `shown`, work out the elapsed time and whether the panes stack:

```tsx title="app/routes/page.tsx"
const elapsed = timing === selected ? now - startedAt : 0;
const narrow = columns < 60;
```

## Start the clock

Add two mount effects above the save effect. The first ticks the clock once a second. The second follows the terminal's size:

```tsx title="app/routes/page.tsx"
useEffect(() => {
  const tick = setInterval(() => setNow((n) => n + 1), 1000);
  return () => clearInterval(tick);
}, []);

useEffect(() => {
  const onResize = () => setColumns(stdout.columns);
  stdout.on("resize", onResize);
  return () => stdout.off("resize", onResize);
}, []);
```

`setNow((n) => n + 1)` adds one to the clock as it stands when the timer fires. Write the updater form in a timer callback, as you would under Ink, since the callback was made on the first render.

`stdout.columns` and `stdout.rows` are the terminal's size. `stdout.on("resize", f)` runs `f` whenever the size changes and `stdout.off` in the cleanup takes it off.

## Time a task and quit with a code

Add `timed` below `change`:

```tsx title="app/routes/page.tsx"
const change = (next: Task[]) => {
  setTasks(next);
  setSaving(true);
};

const timed = () => tasks.map((task, index) => (index === timing ? { ...task, spent: (task.spent ?? 0) + now - startedAt } : task));
```

`timed` is the list with the running time added to the task being timed. With no task timed it is the list as it is. Add two branches to the page's handler, after the `g` line:

```tsx title="app/routes/page.tsx"
if (input === "t") {
  if (timing === null) {
    setTiming(selected);
    setStartedAt(now);
  } else {
    change(timed());
    setTiming(null);
  }
}
if (input === "q") {
  save(where, timed());
  exit(open === 0 ? 0 : 1);
}
```

`t` starts the timer on the selected task and stops it again. `q` writes the list at once, since the half-second save from 640 would never fire after the app has gone, then calls `exit` with a code: 0 when every task is done, 1 while any is open. `fsr dev --terminal` exits with that code.

## Show the time and follow the width

Each pane takes a `stacked` prop, the list marks the task being timed and the detail pane shows the time spent. Change the two prop types and the start of each pane:

```tsx title="app/routes/page.tsx"
type ListProps = { stacked: boolean; tasks: Task[]; selected: number; timing: number | null; active: boolean; onMove: (by: number) => void; onToggle: () => void };

function ListPane({ stacked, tasks, selected, timing, active, onMove, onToggle }: ListProps) {
```

```tsx title="app/routes/page.tsx"
type DetailProps = { stacked: boolean; task: Task | undefined; elapsed: number; active: boolean; onRemove: () => void };

function DetailPane({ stacked, task, elapsed, active, onRemove }: DetailProps) {
```

In both panes, the outer `Box` keeps `flexGrow={1}` and its basis follows `stacked`:

```tsx title="app/routes/page.tsx"
<Box flexDirection="column" flexGrow={1} flexBasis={stacked ? "auto" : 0} borderStyle="round" borderColor={isFocused ? "cyan" : "gray"} paddingX={1}>
```

Side by side, a basis of 0 splits the row in halves as before. Stacked, `"auto"` gives each pane the height of its own lines.

In the list, add the marker after `{task.title}`:

```tsx title="app/routes/page.tsx"
{index === timing ? " (timing)" : ""}
```

In the detail pane, add a line under the status:

```tsx title="app/routes/page.tsx"
<Text>spent {clock((task.spent ?? 0) + elapsed)}</Text>
```

In `Tasks`, the outer `Box` follows the terminal up to 80 columns and the panes stack when it is narrow:

```tsx title="app/routes/page.tsx"
<Box flexDirection="column" width={Math.min(columns, 80)}>
```

```tsx title="app/routes/page.tsx"
<Box flexDirection={narrow ? "column" : "row"}>
  <ListPane stacked={narrow} tasks={shown} selected={selected} timing={timing} active={!adding} onMove={move} onToggle={toggle} />
  <DetailPane stacked={narrow} task={shown[selected]} elapsed={elapsed} active={!adding} onRemove={remove} />
</Box>
```

Change the footer's hint to `"t times, q quits"`.

## Run it

```shell-session
$ fsr dev app --terminal
```

Press `t`. The list shows `(timing)` beside the task and the detail pane counts up. Press `t` again after a few seconds to stop it, then `q`:

```text
 Tasks tasks.json                                                        1 open
╭──────────────────────────────────────╮╭──────────────────────────────────────╮
│ > Tag a release                      ││ Tag a release                        │
│   Answer the open issues             ││ open                                 │
│                                      ││ spent 0:04                           │
│                                      ││ x removes it                         │
╰──────────────────────────────────────╯╰──────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────╮
│ git log                                                                      │
│ 467741c rerun git on g                                                       │
│ 80204f3 add a git pane                                                       │
│ 12d479d save tasks to a file                                                 │
│ 96f7519 start the task list                                                  │
╰──────────────────────────────────────────────────────────────────────────────╯
 t times, q quits                                                      ⠸ saving
```

The spinner is still running when the app quits. `q` wrote the file itself, so nothing is lost. One task is open, so the exit code is 1:

```shell-session
$ echo $?
1
$ cat ~/.tasks/tasks.json
[
  {
    "title": "Tag a release",
    "done": false,
    "spent": 4
  },
  {
    "title": "Answer the open issues",
    "done": true
  }
]
```

The time is counted in whole ticks of the clock, so it can read a second more or less than you waited. Mark every task done and `q` exits with 0, so a script can run `fsr dev app --terminal && echo all done`.

Run it again and narrow the terminal to 50 columns. The panes stack:

```text
 Tasks tasks.json                          1 open
╭────────────────────────────────────────────────╮
│ > Tag a release                                │
│   Answer the open issues                       │
╰────────────────────────────────────────────────╯
╭────────────────────────────────────────────────╮
│ Tag a release                                  │
│ open                                           │
│ spent 0:04                                     │
│ x removes it                                   │
╰────────────────────────────────────────────────╯
╭────────────────────────────────────────────────╮
│ git log                                        │
│ 467741c rerun git on g                         │
│ 80204f3 add a git pane                         │
│ 12d479d save tasks to a file                   │
│ 96f7519 start the task list                    │
╰────────────────────────────────────────────────╯
 t times, q quits                           saved
```

Next up: [680. Bring an Ink tool over](https://snapfirers.com/fsr/learn/680-bring-an-ink-tool-over).
