heartwood every commit a ring

Add pause, manual entry, import, tag autocomplete, and inline keybind hints

8f103555 by Isaac Bythewood · 23 days ago

Add pause, manual entry, import, tag autocomplete, and inline keybind hints

Groups log entries by day with per-day totals. Adds a pause/resume timer
(alt+p) that preserves elapsed time. Adds manual entry creation from the
log page. Adds CSV/JSON import alongside JSON and Markdown export. Adds
a #tag autocomplete dropdown to note inputs. Surfaces keybinds via
tooltips and a new shortcuts overlay (press ?) so the About page is no
longer the only reference.
modified components/HotKeysMapping.js
@@ -1,10 +1,11 @@import React, { useContext } from "react";import React, { useContext, useState } from "react";import { useRouter } from "next/router";import { GlobalHotKeys, configure } from "react-hotkeys";import PropTypes from "prop-types";import { toast } from "react-toastify";import { Context } from "../components/context";import KeyHelpOverlay from "./keyHelpOverlay";import contextStrings from "../l10n/context";configure({
@@ -13,6 +14,13 @@ configure({    if (keyEvent.key === "Enter" || keyEvent.key === " ") {      return true;    }    const t = keyEvent.target;    const isTypingField =      t &&      (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);    if (isTypingField && keyEvent.key === "?") {      return true;    }    return false;  },});
@@ -20,6 +28,7 @@ configure({const keyMap = {  RESET: "alt+r",  ADD_LOG: "alt+a",  PAUSE_TOGGLE: "alt+p",  TIMER_PAGE: "alt+t",  LOG_PAGE: "alt+l",  ABOUT_PAGE: "alt+o",
@@ -29,10 +38,12 @@ const keyMap = {  LOG_PREVIOUS: "ArrowUp",  LOG_EDIT: "alt+e",  LOG_DELETE_SINGLE: "alt+d",  KEY_HELP: ["shift+/", "?"],};const HotKeysMapping = (props) => {  const { state, dispatch } = useContext(Context);  const [showHelp, setShowHelp] = useState(false);  const router = useRouter();
@@ -48,6 +59,12 @@ const HotKeysMapping = (props) => {        toast.success(contextStrings.addedEntry);      }    },    PAUSE_TOGGLE: (event) => {      event.preventDefault();      dispatch({        type: state.timerPausedAt ? "RESUME_TIMER" : "PAUSE_TIMER",      });    },    TIMER_PAGE: (event) => {      event.preventDefault();      router.push("/");
@@ -90,11 +107,16 @@ const HotKeysMapping = (props) => {      dispatch({ type: "REMOVE_LOG" });      toast.error(contextStrings.deletedEntry);    },    KEY_HELP: (event) => {      event.preventDefault();      setShowHelp((v) => !v);    },  };  return (    <GlobalHotKeys keyMap={keyMap} handlers={handlers}>      {props.children}      <KeyHelpOverlay open={showHelp} onClose={() => setShowHelp(false)} />    </GlobalHotKeys>  );};
modified components/context.js
@@ -12,6 +12,7 @@ const initialState = {  note: "",  language: "en",  timer: new Date(),  timerPausedAt: null,  log: [],  logSelectedEntry: "",  edit: false,
@@ -36,9 +37,30 @@ const reducer = (state, action) => {      newState = {        ...state,        timer: new Date(),        timerPausedAt: null,      };      localForage.setItem("context", newState);      return newState;    case "PAUSE_TIMER":      if (state.timerPausedAt) return state;      newState = {        ...state,        timerPausedAt: new Date(),      };      localForage.setItem("context", newState);      return newState;    case "RESUME_TIMER": {      if (!state.timerPausedAt) return state;      const pausedElapsed =        +new Date(state.timerPausedAt) - +new Date(state.timer);      newState = {        ...state,        timer: new Date(Date.now() - pausedElapsed),        timerPausedAt: null,      };      localForage.setItem("context", newState);      return newState;    }    case "NOTE_UPDATED":      newState = {        ...state,
@@ -46,15 +68,19 @@ const reducer = (state, action) => {      };      localForage.setItem("context", newState);      return newState;    case "ADD_LOG":    case "ADD_LOG": {      const end = state.timerPausedAt        ? new Date(state.timerPausedAt)        : new Date();      newState = {        ...state,        timer: new Date(),        timerPausedAt: null,        log: [          {            id: newId(),            start: state.timer,            end: new Date(),            end,            note: state.note,            tags: state.note              .split(/\s+/)
@@ -67,6 +93,39 @@ const reducer = (state, action) => {      };      localForage.setItem("context", newState);      return newState;    }    case "IMPORT_LOG": {      const existingIds = new Set(state.log.map((e) => e.id));      const prepared = action.entries.map((e) => ({        ...e,        id: !e.id || existingIds.has(e.id) ? newId() : e.id,      }));      const merged = [...state.log, ...prepared].sort(        (a, b) => +new Date(b.start) - +new Date(a.start)      );      newState = { ...state, log: merged };      localForage.setItem("context", newState);      return newState;    }    case "ADD_MANUAL_LOG": {      const { start, end, note } = action;      const entry = {        id: newId(),        start,        end,        note,        tags: note          .split(/\s+/)          .filter((word) => word.startsWith("#") && word.length > 1)          .map((word) => word.toLowerCase()),      };      const merged = [...state.log, entry].sort(        (a, b) => +new Date(b.start) - +new Date(a.start)      );      newState = { ...state, log: merged };      localForage.setItem("context", newState);      return newState;    }    case "EDIT_LOG":      newState = {        ...state,
modified components/entry.js
@@ -1,9 +1,10 @@import React, { useContext, useRef, useEffect, useCallback } from "react";import { useForm } from "react-hook-form";import React, { useContext, useRef, useEffect, useCallback, useMemo } from "react";import { useForm, Controller } from "react-hook-form";import PropTypes from "prop-types";import { timeString } from "../utils/time";import { Context } from "../components/context";import TagNoteInput from "./tagNoteInput";import styles from "../styles/components/entry.module.css";
@@ -22,13 +23,18 @@ const toDatetimeLocal = (date) => {const Entry = React.forwardRef(  ({ entry, removeEntry, isSelected, style }, forwardedRef) => {    const { state, dispatch } = useContext(Context);    const { register, handleSubmit, reset } = useForm({    const { register, handleSubmit, reset, control } = useForm({      defaultValues: {        note: entry.note,        start: toDatetimeLocal(entry.start),        end: toDatetimeLocal(entry.end),      },    });    const allTags = useMemo(() => {      const s = new Set();      for (const e of state.log) for (const t of e.tags || []) s.add(t);      return [...s].sort();    }, [state.log]);    const focusedEntry = useRef(null);    const isEditing = state.edit && isSelected == entry.id;
@@ -123,12 +129,20 @@ const Entry = React.forwardRef(              </label>            </div>            <div className={styles.entryNote}>              <input                {...register("note")}                className={styles.entryNoteInput}                autoFocus                aria-label="Note"                placeholder="Note with #tags"              <Controller                name="note"                control={control}                render={({ field }) => (                  <TagNoteInput                    className={styles.entryNoteInput}                    aria-label="Note"                    placeholder="Note with #tags"                    autoFocus                    value={field.value}                    onChange={field.onChange}                    allTags={allTags}                  />                )}              />            </div>            <button
@@ -166,6 +180,7 @@ const Entry = React.forwardRef(            <button              className={`${styles.entryButton} ${styles.entryEdit}`}              aria-label="Edit"              title="Edit (⎇+E)"              onClick={() => {                dispatch({ type: "SELECT_LOG_ITEM", id: entry.id });                dispatch({ type: "TOGGLE_EDITION", edit: true });
@@ -176,6 +191,7 @@ const Entry = React.forwardRef(            <button              className={`${styles.entryButton} ${styles.entryRemove}`}              aria-label="Delete"              title="Delete (⎇+D)"              onClick={() => {                dispatch({ type: "SELECT_LOG_ITEM", id: "" });                removeEntry(entry.id);
added components/keyHelpOverlay.js
@@ -0,0 +1,85 @@import React, { useEffect } from "react";import PropTypes from "prop-types";import styles from "../styles/components/keyHelpOverlay.module.css";const ROWS = [  { section: "Timer", keys: [    ["⎇+A", "Add log entry"],    ["⎇+R", "Reset timer"],    ["⎇+P", "Pause / resume"],  ]},  { section: "Navigation", keys: [    ["⎇+T", "Timer page"],    ["⎇+L", "Log page"],    ["⎇+S", "Summary page"],    ["⎇+O", "About page"],  ]},  { section: "Log", keys: [    ["↓", "Next entry"],    ["↑", "Previous entry"],    ["⎇+E", "Edit entry"],    ["⎇+D", "Delete entry"],    ["⎇+C", "Clear all"],  ]},  { section: "Help", keys: [    ["?", "Toggle this overlay"],    ["Esc", "Close overlay"],  ]},];const KeyHelpOverlay = ({ open, onClose }) => {  useEffect(() => {    if (!open) return;    const onKey = (e) => {      if (e.key === "Escape") onClose();    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  }, [open, onClose]);  if (!open) return null;  return (    <div className={styles.backdrop} onClick={onClose}>      <div        className={styles.panel}        role="dialog"        aria-label="Keyboard shortcuts"        onClick={(e) => e.stopPropagation()}      >        <div className={styles.header}>          <span className={styles.title}>Shortcuts</span>          <button            className={styles.close}            onClick={onClose}            aria-label="Close"            title="Esc"          >          </button>        </div>        <div className={styles.grid}>          {ROWS.map(({ section, keys }) => (            <div key={section} className={styles.column}>              <div className={styles.sectionLabel}>{section}</div>              {keys.map(([k, label]) => (                <div key={k} className={styles.row}>                  <span className={styles.key}>{k}</span>                  <span className={styles.label}>{label}</span>                </div>              ))}            </div>          ))}        </div>      </div>    </div>  );};KeyHelpOverlay.propTypes = {  open: PropTypes.bool.isRequired,  onClose: PropTypes.func.isRequired,};export default KeyHelpOverlay;
added components/newEntryForm.js
@@ -0,0 +1,145 @@import React, { useContext, useMemo } from "react";import { useForm, Controller } from "react-hook-form";import PropTypes from "prop-types";import { Context } from "./context";import TagNoteInput from "./tagNoteInput";import styles from "../styles/components/entry.module.css";const pad = (n) => String(n).padStart(2, "0");const toDatetimeLocal = (date) =>  `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;const defaultStart = (log) => {  if (log.length > 0) {    const mostRecentEnd = new Date(log[0].end);    const now = new Date();    return mostRecentEnd < now ? mostRecentEnd : new Date(now.getTime() - 30 * 60 * 1000);  }  return new Date(Date.now() - 30 * 60 * 1000);};const NewEntryForm = ({ onClose }) => {  const { state, dispatch } = useContext(Context);  const start = defaultStart(state.log);  const end = new Date();  const { register, handleSubmit, control } = useForm({    defaultValues: {      note: "",      start: toDatetimeLocal(start),      end: toDatetimeLocal(end),    },  });  const allTags = useMemo(() => {    const s = new Set();    for (const e of state.log) for (const t of e.tags || []) s.add(t);    return [...s].sort();  }, [state.log]);  const onSubmit = (data) => {    const s = new Date(data.start);    const e = new Date(data.end);    if (isNaN(s) || isNaN(e) || e < s) {      onClose();      return;    }    dispatch({      type: "ADD_MANUAL_LOG",      start: s,      end: e,      note: data.note || "",    });    onClose();  };  return (    <div      className={`${styles.entryContainer} ${styles.editing} ${styles.selected}`}    >      <form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}>        <div className={`${styles.entryTime} ${styles.entryTimeEditing}`}>          <div className={styles.entryDuration}>New entry</div>          <label className={styles.entryTimeRow}>            <span className={styles.entryTimeRowLabel}>From</span>            <input              type="datetime-local"              step="1"              aria-label="Start time"              className={styles.entryTimeInput}              {...register("start")}            />          </label>          <label className={styles.entryTimeRow}>            <span className={styles.entryTimeRowLabel}>To</span>            <input              type="datetime-local"              step="1"              aria-label="End time"              className={styles.entryTimeInput}              {...register("end")}            />          </label>        </div>        <div className={styles.entryNote}>          <Controller            name="note"            control={control}            render={({ field }) => (              <TagNoteInput                className={styles.entryNoteInput}                aria-label="Note"                placeholder="Note with #tags"                autoFocus                value={field.value}                onChange={field.onChange}                allTags={allTags}              />            )}          />        </div>        <button          className={`${styles.entryButton} ${styles.entrySubmit}`}          type="submit"          aria-label="Save"          title="Save (Enter)"        >          <svg            xmlns="http://www.w3.org/2000/svg"            width="18"            height="18"            viewBox="0 0 24 24"            fill="currentColor"            aria-hidden="true"          >            <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z" />          </svg>        </button>        <button          className={`${styles.entryButton} ${styles.entryRemove}`}          type="button"          aria-label="Cancel"          title="Cancel"          onClick={onClose}        >          <svg            xmlns="http://www.w3.org/2000/svg"            width="18"            height="18"            viewBox="0 0 24 24"            fill="currentColor"            aria-hidden="true"          >            <path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />          </svg>        </button>      </form>    </div>  );};NewEntryForm.propTypes = {  onClose: PropTypes.func.isRequired,};export default NewEntryForm;
modified components/sidebar.js
@@ -36,6 +36,7 @@ const Sidebar = () => {          href="/"          className={`${styles.pageLink} ${router.pathname === "/" ? styles.pageLinkActive : ""}`.trim()}          aria-label={strings.timer}          title={`${strings.timer} (⎇+T)`}        >          <TimerIcon />        </Link>
@@ -43,6 +44,7 @@ const Sidebar = () => {          href="/log"          className={`${styles.pageLink} ${router.pathname === "/log" ? styles.pageLinkActive : ""}`.trim()}          aria-label={strings.log}          title={`${strings.log} (⎇+L)`}        >          <LogIcon />        </Link>
@@ -50,11 +52,17 @@ const Sidebar = () => {          href="/summary"          className={`${styles.pageLink} ${router.pathname === "/summary" ? styles.pageLinkActive : ""}`.trim()}          aria-label={strings.summary}          title={`${strings.summary} (⎇+S)`}        >          <SummaryIcon />        </Link>      </div>      <Link href="/about" className={styles.about} aria-label={strings.about}>      <Link        href="/about"        className={styles.about}        aria-label={strings.about}        title={`${strings.about} (⎇+O) — press ? for shortcuts`}      >        <HelpIcon />      </Link>    </aside>
added components/tagNoteInput.js
@@ -0,0 +1,161 @@import React, { useState, useRef, useMemo, useCallback, useEffect } from "react";import PropTypes from "prop-types";import styles from "../styles/components/tagNoteInput.module.css";const tokenAtCursor = (text, cursorPos) => {  let start = cursorPos;  while (start > 0 && !/\s/.test(text[start - 1])) start--;  let end = cursorPos;  while (end < text.length && !/\s/.test(text[end])) end++;  return { start, end, token: text.slice(start, end) };};const TagNoteInput = React.forwardRef(function TagNoteInput(  {    value,    onChange,    allTags,    className,    wrapperClassName,    placeholder,    autoFocus,    "aria-label": ariaLabel,  },  forwardedRef) {  const inputRef = useRef(null);  const [cursorPos, setCursorPos] = useState(0);  const [isFocused, setIsFocused] = useState(false);  const [highlightIndex, setHighlightIndex] = useState(0);  const [pendingCursor, setPendingCursor] = useState(null);  const setRefs = useCallback(    (node) => {      inputRef.current = node;      if (typeof forwardedRef === "function") forwardedRef(node);      else if (forwardedRef && "current" in forwardedRef)        forwardedRef.current = node;    },    [forwardedRef]  );  useEffect(() => {    if (pendingCursor != null && inputRef.current) {      inputRef.current.setSelectionRange(pendingCursor, pendingCursor);      setPendingCursor(null);    }  }, [pendingCursor]);  const { start, end, token } = useMemo(    () => tokenAtCursor(value || "", cursorPos),    [value, cursorPos]  );  const suggestions = useMemo(() => {    if (!isFocused) return [];    if (!token.startsWith("#") || token.length < 2) return [];    const needle = token.toLowerCase();    return allTags      .filter((t) => t !== needle && t.startsWith(needle))      .slice(0, 8);  }, [isFocused, token, allTags]);  useEffect(() => {    if (highlightIndex >= suggestions.length) setHighlightIndex(0);  }, [suggestions.length, highlightIndex]);  const applySuggestion = (tag) => {    const before = (value || "").slice(0, start);    const after = (value || "").slice(end);    const needsSpace = after.length === 0 || !/^\s/.test(after);    const replacement = tag + (needsSpace ? " " : "");    const next = before + replacement + after;    const newCursor = before.length + replacement.length;    onChange(next);    setPendingCursor(newCursor);  };  const handleKeyDown = (e) => {    if (suggestions.length === 0) return;    if (e.key === "ArrowDown") {      e.preventDefault();      setHighlightIndex((i) => (i + 1) % suggestions.length);    } else if (e.key === "ArrowUp") {      e.preventDefault();      setHighlightIndex(        (i) => (i - 1 + suggestions.length) % suggestions.length      );    } else if (e.key === "Tab" || (e.key === "Enter" && suggestions.length)) {      if (e.key === "Enter" && !isFocused) return;      e.preventDefault();      applySuggestion(suggestions[highlightIndex]);    } else if (e.key === "Escape") {      setIsFocused(false);      e.stopPropagation();    }  };  const handleChange = (e) => {    onChange(e.target.value);    setCursorPos(e.target.selectionStart ?? e.target.value.length);    setHighlightIndex(0);  };  const handleSelect = (e) => {    setCursorPos(e.target.selectionStart ?? 0);  };  return (    <div className={`${styles.wrap} ${wrapperClassName || ""}`.trim()}>      <input        type="text"        ref={setRefs}        className={className}        value={value || ""}        onChange={handleChange}        onKeyDown={handleKeyDown}        onSelect={handleSelect}        onFocus={() => setIsFocused(true)}        onBlur={() => setTimeout(() => setIsFocused(false), 120)}        placeholder={placeholder}        aria-label={ariaLabel}        autoFocus={autoFocus}        autoComplete="off"      />      {suggestions.length > 0 && (        <ul          className={styles.list}          role="listbox"          onMouseDown={(e) => e.preventDefault()}        >          {suggestions.map((tag, i) => (            <li              key={tag}              role="option"              aria-selected={i === highlightIndex}              className={`${styles.item} ${i === highlightIndex ? styles.itemActive : ""}`.trim()}              onMouseEnter={() => setHighlightIndex(i)}              onClick={() => applySuggestion(tag)}            >              {tag}            </li>          ))}        </ul>      )}    </div>  );});TagNoteInput.propTypes = {  value: PropTypes.string,  onChange: PropTypes.func.isRequired,  allTags: PropTypes.arrayOf(PropTypes.string).isRequired,  className: PropTypes.string,  wrapperClassName: PropTypes.string,  placeholder: PropTypes.string,  autoFocus: PropTypes.bool,  "aria-label": PropTypes.string,};export default TagNoteInput;
modified components/timer.js
@@ -1,7 +1,8 @@import React, { useState, useEffect, useContext, useRef } from "react";import React, { useState, useEffect, useContext, useRef, useMemo } from "react";import { toast } from "react-toastify";import { Context } from "./context";import TagNoteInput from "./tagNoteInput";import strings from "../l10n/timer";import contextStrings from "../l10n/context";import { timeString, timeDiff } from "../utils/time";
@@ -10,14 +11,25 @@ import styles from "../styles/components/timer.module.css";const Timer = () => {  const { state, dispatch } = useContext(Context);  const [time, setTime] = useState(timeString(timeDiff(state.timer)));  const isPaused = !!state.timerPausedAt;  const initialElapsed = isPaused    ? +new Date(state.timerPausedAt) - +new Date(state.timer)    : timeDiff(state.timer);  const [time, setTime] = useState(timeString(initialElapsed));  const refToMain = useRef(null);  strings.setLanguage(state.language);  useEffect(() => {    if (isPaused) {      const frozen = timeString(        +new Date(state.timerPausedAt) - +new Date(state.timer)      );      setTime(frozen);      document.title = `${frozen} — Timelite`;      return;    }    setTime(timeString(timeDiff(state.timer)));    const timerInterval = setInterval(() => {      const timeCurrent = timeString(timeDiff(state.timer));      setTime(timeCurrent);
@@ -26,7 +38,7 @@ const Timer = () => {    return () => {      clearInterval(timerInterval);    };  }, [state.timer]);  }, [state.timer, state.timerPausedAt, isPaused]);  useEffect(() => {    if (refToMain.current) {
@@ -34,6 +46,12 @@ const Timer = () => {    }  }, []);  const allTags = useMemo(() => {    const s = new Set();    for (const e of state.log) for (const t of e.tags || []) s.add(t);    return [...s].sort();  }, [state.log]);  const submitForm = (e) => {    e.preventDefault();    if (state.note.trim()) {
@@ -46,21 +64,24 @@ const Timer = () => {  return (    <>      <div className={styles.time} suppressHydrationWarning>      <div        className={`${styles.time} ${isPaused ? styles.timePaused : ""}`.trim()}        suppressHydrationWarning      >        {time}        {isPaused && <span className={styles.pausedBadge}>{strings.paused}</span>}      </div>      <form onSubmit={submitForm}>        <div className={styles.inputs}>          <input          <TagNoteInput            ref={refToMain}            className={styles.note}            type="text"            wrapperClassName={styles.noteWrap}            aria-label={strings.note}            placeholder={strings.note}            value={state.note || ""}            ref={refToMain}            onChange={(e) =>              dispatch({ type: "NOTE_UPDATED", note: e.target.value })            }            allTags={allTags}            onChange={(val) => dispatch({ type: "NOTE_UPDATED", note: val })}          />        </div>        <div className={styles.buttons}>
@@ -68,12 +89,24 @@ const Timer = () => {            className={`${styles.button} ${styles.resetButton} timer__button`}            type="reset"            onClick={() => dispatch({ type: "NEW_TIMER" })}            title="⎇+R"          >            - {strings.reset}          </button>          <button            className={`${styles.button} ${styles.pauseButton} timer__button`}            type="button"            onClick={() =>              dispatch({ type: isPaused ? "RESUME_TIMER" : "PAUSE_TIMER" })            }            title="⎇+P"          >            {isPaused ? strings.resume : strings.pause}          </button>          <button            className={`${styles.button} ${styles.addButton} timer__button`}            type="submit"            title="⎇+A"          >            {strings.add} +          </button>
modified l10n/about.js
@@ -20,6 +20,8 @@ const strings = new LocalizedStrings({    keyEditEntry: "Edit log entry",    keyDeleteSingleEntry: "Delete single log entry",    keyClearLog: "Clear all log entries",    keyPauseToggle: "Pause or resume timer",    keyHelp: "Toggle shortcuts overlay",  },  jp: {    title: "タイムライト?",
@@ -40,6 +42,8 @@ const strings = new LocalizedStrings({    keyEditEntry: "ログエントリの編集",    keyDeleteSingleEntry: "単一のログエントリを削除する",    keyClearLog: "すべてのログエントリをクリアする",    keyPauseToggle: "タイマーを一時停止/再開",    keyHelp: "ショートカット一覧を切替",  },  pl: {    title: "Timelite?",
@@ -60,6 +64,8 @@ const strings = new LocalizedStrings({    keyEditEntry: "Edytuj wpis dziennika",    keyDeleteSingleEntry: "Skasuj pojedynczy wpis dziennika",    keyClearLog: "Wyczyść wszystkie wpisy dziennika",    keyPauseToggle: "Pauza lub wznowienie zegara",    keyHelp: "Przełącz panel skrótów",  },});
modified l10n/log.js
@@ -14,6 +14,12 @@ const strings = new LocalizedStrings({    show: "Show All",    clear: "Clear",    export: "Export CSV",    today: "Today",    yesterday: "Yesterday",    addEntry: "Add entry",    exportMd: "Export MD",    exportJson: "Export JSON",    importData: "Import",  },  jp: {    pageTitle: "ログ",
@@ -28,6 +34,12 @@ const strings = new LocalizedStrings({    show: "すべて",    clear: "すべてクリア",    export: "CSVに書き出す",    today: "今日",    yesterday: "昨日",    addEntry: "エントリ追加",    exportMd: "MDに書き出す",    exportJson: "JSONに書き出す",    importData: "インポート",  },  pl: {    pageTitle: "Dziennik",
@@ -42,6 +54,12 @@ const strings = new LocalizedStrings({    show: "Pokaż wszystko",    clear: "Wyczyść",    export: "Eksportuj CSV",    today: "Dzisiaj",    yesterday: "Wczoraj",    addEntry: "Dodaj wpis",    exportMd: "Eksportuj MD",    exportJson: "Eksportuj JSON",    importData: "Importuj",  },});
modified l10n/timer.js
@@ -5,16 +5,25 @@ const strings = new LocalizedStrings({    note: "Add a note with a #tag",    reset: "Reset",    add: "Add",    pause: "Pause",    resume: "Resume",    paused: "Paused",  },  jp: {    note: "#tagでメモを追加",    reset: "リセット",    add: "追加する",    pause: "一時停止",    resume: "再開",    paused: "一時停止中",  },  pl: {    note: "Dodaj wpis z #tagiem",    reset: "Zeruj",    add: "Dodaj",    pause: "Pauza",    resume: "Wznów",    paused: "Zatrzymany",  },});
modified pages/about.js
@@ -55,6 +55,9 @@ const About = () => {              <div className={styles.keysDescription}>                <span>⎇+a</span> {strings.keyAddLog}              </div>              <div className={styles.keysDescription}>                <span>⎇+p</span> {strings.keyPauseToggle}              </div>              <h2 className="section-label">{strings.sectionNavigation}</h2>              <div className={styles.keysDescription}>                <span>⎇+t</span> {strings.keyTimerPage}
@@ -86,6 +89,9 @@ const About = () => {              <div className={styles.keysDescription}>                <span>⎇+c</span> {strings.keyClearLog}              </div>              <div className={styles.keysDescription}>                <span>?</span> {strings.keyHelp}              </div>            </div>          </div>
modified pages/log.js
@@ -6,9 +6,16 @@ import { toast } from "react-toastify";import Page from "../components/page";import { Context } from "../components/context";import Entry from "../components/entry";import NewEntryForm from "../components/newEntryForm";import strings from "../l10n/log";import contextStrings from "../l10n/context";import { timeString } from "../utils/time";import {  parseImport,  buildJsonExport,  buildMarkdownExport,  downloadTextFile,} from "../utils/importExport";import styles from "../styles/pages/log.module.css";
@@ -28,11 +35,79 @@ const sanitizeCell = (value) => {  return str;};const dayKeyOf = (date) => {  const d = date instanceof Date ? date : new Date(date);  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;};const dayLabelOf = (date, strings) => {  const d = date instanceof Date ? date : new Date(date);  const today = new Date();  today.setHours(0, 0, 0, 0);  const sameDay = (a, b) =>    a.getFullYear() === b.getFullYear() &&    a.getMonth() === b.getMonth() &&    a.getDate() === b.getDate();  const yesterday = new Date(today.getTime() - 86400000);  if (sameDay(d, today)) return strings.today;  if (sameDay(d, yesterday)) return strings.yesterday;  return null;};const Log = () => {  const { state, dispatch } = useContext(Context);  const [filter, setFilter] = useState({ type: "SHOW_ALL" });  const [addingEntry, setAddingEntry] = useState(false);  const importInputRef = useRef(null);  const nodeRefs = useRef(new Map());  const handleImportClick = () => {    if (importInputRef.current) importInputRef.current.click();  };  const handleImportFile = async (event) => {    const file = event.target.files && event.target.files[0];    event.target.value = "";    if (!file) return;    try {      const text = await file.text();      const entries = parseImport(text, file.name);      if (entries.length === 0) {        toast.error("Nothing to import.");        return;      }      if (        !window.confirm(          `Import ${entries.length} entr${entries.length === 1 ? "y" : "ies"} and merge into your log?`        )      ) {        return;      }      dispatch({ type: "IMPORT_LOG", entries });      toast.success(`Imported ${entries.length} entries.`);    } catch (err) {      toast.error(`Import failed: ${err.message || err}`);    }  };  const handleExportMarkdown = () => {    const md = buildMarkdownExport(state.log);    downloadTextFile(      md,      `timelite-export-${new Date().toISOString().slice(0, 10)}.md`,      "text/markdown;charset=utf-8"    );  };  const handleExportJson = () => {    const json = buildJsonExport(state.log);    downloadTextFile(      json,      `timelite-export-${new Date().toISOString().slice(0, 10)}.json`,      "application/json;charset=utf-8"    );  };  strings.setLanguage(state.language);  const getTags = (entries) => {
@@ -110,12 +185,53 @@ const Log = () => {  const visibleEntries = getVisibleEntries(state.log, filter);  const isEmpty = state.log.length === 0;  const groupedByDay = useMemo(() => {    const groups = [];    let current = null;    for (const entry of visibleEntries) {      const key = dayKeyOf(entry.start);      if (!current || current.key !== key) {        current = { key, date: new Date(entry.start), totalMs: 0, entries: [] };        groups.push(current);      }      current.entries.push(entry);      current.totalMs +=        +new Date(entry.end) - +new Date(entry.start);    }    return groups;  }, [visibleEntries]);  if (isEmpty) {    return (      <Page title="Log">        <div className="page-grid">          <main className="page-main">            <h1 className="page-title">{strings.pageTitle}</h1>            {addingEntry ? (              <NewEntryForm onClose={() => setAddingEntry(false)} />            ) : (              <div className={styles.emptyActions}>                <button                  className={styles.actionButton}                  onClick={() => setAddingEntry(true)}                >                  + {strings.addEntry}                </button>                <button                  className={styles.actionButton}                  onClick={handleImportClick}                >                  ↑ {strings.importData}                </button>                <input                  ref={importInputRef}                  type="file"                  accept=".csv,.json,text/csv,application/json"                  style={{ display: "none" }}                  onChange={handleImportFile}                />              </div>            )}          </main>        </div>        <div className="empty-state">
@@ -155,19 +271,57 @@ const Log = () => {              <span className={styles.tileLabel}>{strings.entries}</span>              <span className={styles.tileValue}>{state.log.length}</span>            </div>          </div>          <div className={styles.actionsBar}>            <button              className={styles.actionButton}              onClick={() => setAddingEntry(true)}              disabled={addingEntry}            >              + {strings.addEntry}            </button>            <button              className={styles.actionButton}              onClick={handleImportClick}            >              ↑ {strings.importData}            </button>            <CSVLink              className={styles.tileExport}              className={styles.actionButton}              data={csvData}              headers={CSV_HEADERS}              filename={`timelite-export-${new Date()                .toISOString()                .slice(0, 10)}.csv`}            >              <span className={styles.tileLabel}>{strings.export}</span>              <span className={styles.tileExportGlyph}>↓</span>              ↓ {strings.export}            </CSVLink>            <button              className={styles.actionButton}              onClick={handleExportMarkdown}            >              ↓ {strings.exportMd}            </button>            <button              className={styles.actionButton}              onClick={handleExportJson}            >              ↓ {strings.exportJson}            </button>            <input              ref={importInputRef}              type="file"              accept=".csv,.json,text/csv,application/json"              style={{ display: "none" }}              onChange={handleImportFile}            />          </div>          {addingEntry && (            <NewEntryForm onClose={() => setAddingEntry(false)} />          )}          {getTags(state.log).length > 0 && (            <div className={styles.topBar}>              <div className={styles.filters}>
@@ -198,7 +352,11 @@ const Log = () => {                  {strings.clear} {filter.tag}                </button>              ) : (                <button className={styles.reset} onClick={clearAll}>                <button                  className={styles.reset}                  onClick={clearAll}                  title="⎇+C"                >                  {strings.clear}                </button>              )}
@@ -206,34 +364,62 @@ const Log = () => {          )}          {visibleEntries.length > 0 ? (            <TransitionGroup component={null}>              {visibleEntries.map((entry, index) => {                const timeout = (index + 1) * 250;                const transitionDelay = index * 125;                let nodeRef = nodeRefs.current.get(entry.id);                if (!nodeRef) {                  nodeRef = React.createRef();                  nodeRefs.current.set(entry.id, nodeRef);                }                return (                  <CSSTransition                    key={entry.id}                    appear                    timeout={{ appear: timeout, enter: 250, exit: 250 }}                    classNames="fade"                    nodeRef={nodeRef}                  >                    <Entry                      ref={nodeRef}                      style={{ transitionDelay: `${transitionDelay}ms` }}                      entry={entry}                      removeEntry={removeEntry}                      isSelected={state.logSelectedEntry}                    />                  </CSSTransition>                );              })}            </TransitionGroup>            groupedByDay.map((group, groupIndex) => {              const customLabel = dayLabelOf(group.date, strings);              const dateLabel = group.date.toLocaleDateString(undefined, {                weekday: "long",                month: "short",                day: "numeric",              });              return (                <React.Fragment key={group.key}>                  <div className={styles.dayHeader}>                    <div>                      <span className={styles.dayHeaderLabel}>                        {customLabel || dateLabel}                      </span>                      {customLabel && (                        <span className={styles.dayHeaderDate}>                          {dateLabel}                        </span>                      )}                    </div>                    <span className={styles.dayHeaderTotal}>                      {timeString(group.totalMs)}                    </span>                  </div>                  <TransitionGroup component={null}>                    {group.entries.map((entry, index) => {                      const appearIndex = groupIndex * 4 + index;                      const timeout = (appearIndex + 1) * 250;                      const transitionDelay = appearIndex * 125;                      let nodeRef = nodeRefs.current.get(entry.id);                      if (!nodeRef) {                        nodeRef = React.createRef();                        nodeRefs.current.set(entry.id, nodeRef);                      }                      return (                        <CSSTransition                          key={entry.id}                          appear                          timeout={{ appear: timeout, enter: 250, exit: 250 }}                          classNames="fade"                          nodeRef={nodeRef}                        >                          <Entry                            ref={nodeRef}                            style={{ transitionDelay: `${transitionDelay}ms` }}                            entry={entry}                            removeEntry={removeEntry}                            isSelected={state.logSelectedEntry}                          />                        </CSSTransition>                      );                    })}                  </TransitionGroup>                </React.Fragment>              );            })          ) : (            <div className={styles.nothingFiltered}>              {strings.nothingFiltered}
added styles/components/keyHelpOverlay.module.css
@@ -0,0 +1,118 @@.backdrop {  position: fixed;  inset: 0;  background: rgba(10, 9, 7, 0.72);  backdrop-filter: blur(3px);  display: flex;  align-items: center;  justify-content: center;  z-index: 100;  animation: fade 180ms ease;}.panel {  max-width: 720px;  width: calc(100% - 48px);  background: var(--surface);  border: 1px solid var(--border-green);  border-radius: var(--radius);  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7);  padding: 22px 24px 24px;}.header {  display: flex;  justify-content: space-between;  align-items: center;  padding-bottom: 14px;  border-bottom: 1px solid var(--border);  margin-bottom: 18px;}.title {  font-size: 0.82em;  letter-spacing: 0.28em;  text-transform: uppercase;  font-weight: 600;  color: var(--green-bright);}.close {  background: transparent;  border: 1px solid var(--border);  color: var(--text-muted);  font-size: 0.9em;  width: 28px;  height: 28px;  border-radius: 2px;  cursor: pointer;  line-height: 1;}.close:hover {  border-color: var(--terracotta);  color: var(--terracotta-bright);  background: var(--terracotta-dim);}.grid {  display: grid;  grid-template-columns: repeat(2, 1fr);  gap: 22px 32px;}@media (max-width: 599.99px) {  .grid {    grid-template-columns: 1fr;  }}.column {  display: flex;  flex-direction: column;  gap: 8px;}.sectionLabel {  font-size: 0.66em;  text-transform: uppercase;  letter-spacing: 0.22em;  font-weight: 600;  color: var(--text-muted);  margin-bottom: 4px;}.row {  display: flex;  align-items: center;  gap: 12px;  font-size: 0.84em;}.key {  font-family: var(--font-mono);  display: inline-block;  min-width: 56px;  text-align: center;  padding: 4px 8px;  background: var(--bg-deep);  border: 1px solid var(--border);  border-radius: 2px;  color: var(--green-bright);  font-size: 0.9em;  letter-spacing: 0.04em;}.label {  color: var(--text);  letter-spacing: 0.02em;}@keyframes fade {  from {    opacity: 0;  }  to {    opacity: 1;  }}
added styles/components/tagNoteInput.module.css
@@ -0,0 +1,36 @@.wrap {  position: relative;}.list {  position: absolute;  left: 0;  right: 0;  top: 100%;  margin: 4px 0 0;  padding: 4px 0;  list-style: none;  background: var(--surface);  border: 1px solid var(--border-green);  border-radius: var(--radius);  z-index: 20;  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);  max-height: 220px;  overflow-y: auto;  text-align: left;}.item {  padding: 7px 14px;  font-family: var(--font-mono);  font-size: 0.82em;  letter-spacing: 0.04em;  color: var(--text);  cursor: pointer;}.item:hover,.itemActive {  background: var(--green-dim);  color: var(--green-bright);}
modified styles/components/timer.module.css
@@ -36,6 +36,12 @@  margin-top: 1.5rem;}.noteWrap {  display: inline-block;  text-align: left;  max-width: 100%;}.note {  width: 560px;  max-width: calc(100vw - 80px);
@@ -124,6 +130,40 @@  }}.timePaused {  color: var(--amber-bright);  text-shadow: 0 0 24px rgba(201, 168, 76, 0.35);}.pausedBadge {  position: absolute;  right: 50%;  transform: translateX(50%);  top: -6px;  font-size: 0.12em;  letter-spacing: 0.32em;  font-weight: 600;  text-transform: uppercase;  color: var(--amber-bright);  background: var(--amber-dim);  border: 1px solid rgba(201, 168, 76, 0.3);  padding: 4px 10px;  border-radius: 2px;  text-shadow: none;}.pauseButton {  color: var(--amber-bright);  border: 1px solid rgba(201, 168, 76, 0.35);  background: var(--amber-dim);}.pauseButton:hover {  color: var(--amber-bright);  background: rgba(201, 168, 76, 0.18);  border-color: var(--amber-bright);}.resetButton {  color: var(--text-muted);  border: 1px solid var(--border);
modified styles/pages/log.module.css
@@ -37,37 +37,6 @@  letter-spacing: 0.01em;}.tileExport {  position: relative;  background: transparent;  border: 1px solid var(--border-green);  border-radius: var(--radius);  padding: 14px 16px;  display: flex;  flex-direction: column;  align-items: flex-start;  gap: 6px;  color: var(--green-bright);  text-decoration: none;  transition:    background 180ms ease,    border-color 180ms ease,    color 180ms ease;}.tileExport:hover {  background: var(--green-dim);  border-color: var(--green);  color: var(--green-hi);}.tileExportGlyph {  font-size: 1.5em;  font-weight: 700;  line-height: 1;  letter-spacing: 0;}.topBar {  display: flex;  justify-content: space-between;
@@ -158,6 +127,92 @@  }}.actionsBar {  display: flex;  flex-wrap: wrap;  gap: 10px;  margin-bottom: 18px;}.emptyActions {  display: flex;  justify-content: center;  margin-top: 18px;}.actionButton {  font-family: var(--font-mono);  font-size: 0.74em;  letter-spacing: 0.16em;  text-transform: uppercase;  font-weight: 600;  color: var(--green-bright);  background: var(--green-dim);  border: 1px solid var(--border-green);  padding: 9px 16px;  border-radius: 2px;  cursor: pointer;  text-decoration: none;  display: inline-flex;  align-items: center;  transition:    background 180ms ease,    color 180ms ease,    border-color 180ms ease;}.actionButton:hover:not(:disabled) {  color: var(--green-hi);  background: rgba(107, 158, 120, 0.18);  border-color: var(--green-bright);}.actionButton:disabled {  opacity: 0.5;  cursor: not-allowed;}.dayHeader {  display: flex;  justify-content: space-between;  align-items: baseline;  gap: 12px;  padding: 18px 4px 8px;  margin-top: 4px;  border-bottom: 1px solid var(--border);  margin-bottom: 10px;}.dayHeader:first-child {  padding-top: 4px;  margin-top: 0;}.dayHeaderLabel {  font-size: 0.72em;  font-weight: 600;  text-transform: uppercase;  letter-spacing: 0.22em;  color: var(--text-bright);}.dayHeaderDate {  font-size: 0.68em;  color: var(--text-muted);  letter-spacing: 0.1em;  margin-left: 8px;  font-weight: 400;  text-transform: none;}.dayHeaderTotal {  font-size: 0.78em;  font-variant-numeric: tabular-nums;  font-weight: 600;  color: var(--green-bright);  letter-spacing: 0.05em;}.nothingFiltered {  padding: 48px 16px;  text-align: center;
added utils/importExport.js
@@ -0,0 +1,200 @@const stripSanitizePrefix = (s) =>  typeof s === "string" && s.startsWith("'") ? s.slice(1) : s;const parseCsv = (text) => {  const rows = [];  let row = [];  let cell = "";  let inQuotes = false;  for (let i = 0; i < text.length; i++) {    const ch = text[i];    if (inQuotes) {      if (ch === '"') {        if (text[i + 1] === '"') {          cell += '"';          i++;        } else {          inQuotes = false;        }      } else {        cell += ch;      }    } else {      if (ch === '"') {        inQuotes = true;      } else if (ch === ",") {        row.push(cell);        cell = "";      } else if (ch === "\n") {        row.push(cell);        rows.push(row);        row = [];        cell = "";      } else if (ch === "\r") {        // skip      } else {        cell += ch;      }    }  }  if (cell.length > 0 || row.length > 0) {    row.push(cell);    rows.push(row);  }  return rows.filter((r) => r.length > 1 || (r.length === 1 && r[0] !== ""));};const extractTagsFromNote = (note) =>  (note || "")    .split(/\s+/)    .filter((w) => w.startsWith("#") && w.length > 1)    .map((w) => w.toLowerCase());const normalizeEntry = (raw) => {  const start = raw.start instanceof Date ? raw.start : new Date(raw.start);  const end = raw.end instanceof Date ? raw.end : new Date(raw.end);  if (isNaN(+start) || isNaN(+end)) return null;  const note = stripSanitizePrefix(raw.note || "");  let tags = raw.tags;  if (typeof tags === "string") {    tags = stripSanitizePrefix(tags)      .split(/\s+/)      .filter((t) => t.startsWith("#") && t.length > 1)      .map((t) => t.toLowerCase());  } else if (Array.isArray(tags)) {    tags = tags      .filter((t) => typeof t === "string" && t.startsWith("#") && t.length > 1)      .map((t) => t.toLowerCase());  } else {    tags = extractTagsFromNote(note);  }  return {    id: raw.id || null,    start,    end,    note,    tags,  };};export const parseImport = (text, filename) => {  const lower = (filename || "").toLowerCase();  const looksLikeJson = lower.endsWith(".json") || text.trim().startsWith("[") || text.trim().startsWith("{");  if (looksLikeJson) {    const data = JSON.parse(text);    const list = Array.isArray(data) ? data : data.log;    if (!Array.isArray(list)) throw new Error("JSON must be an array of entries");    return list.map(normalizeEntry).filter(Boolean);  }  const rows = parseCsv(text);  if (rows.length < 2) return [];  const [header, ...body] = rows;  const idx = {};  header.forEach((h, i) => {    idx[h.trim().toLowerCase()] = i;  });  if (idx.start === undefined || idx.end === undefined) {    throw new Error("CSV must include start and end columns");  }  return body    .map((r) =>      normalizeEntry({        id: idx.id !== undefined ? r[idx.id] : null,        start: r[idx.start],        end: r[idx.end],        note: idx.note !== undefined ? r[idx.note] : "",        tags: idx.tags !== undefined ? r[idx.tags] : undefined,      })    )    .filter(Boolean);};export const mergeEntries = (existing, incoming, makeId) => {  const existingIds = new Set(existing.map((e) => e.id));  const prepared = incoming.map((e) => ({    ...e,    id: !e.id || existingIds.has(e.id) ? makeId() : e.id,  }));  return [...existing, ...prepared].sort(    (a, b) => +new Date(b.start) - +new Date(a.start)  );};export const buildJsonExport = (log) =>  JSON.stringify(    {      version: 1,      exportedAt: new Date().toISOString(),      log: log.map((e) => ({        id: e.id,        start: (e.start instanceof Date ? e.start : new Date(e.start)).toISOString(),        end: (e.end instanceof Date ? e.end : new Date(e.end)).toISOString(),        note: e.note || "",        tags: e.tags || [],      })),    },    null,    2  );export const buildMarkdownExport = (log) => {  if (log.length === 0) return "# Timelite Log\n\n*(empty)*\n";  const groups = new Map();  for (const e of log) {    const d = e.start instanceof Date ? e.start : new Date(e.start);    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;    if (!groups.has(key)) groups.set(key, { date: d, entries: [] });    groups.get(key).entries.push(e);  }  const sortedKeys = [...groups.keys()].sort().reverse();  const pad = (n) => String(n).padStart(2, "0");  const hms = (ms) => {    const h = Math.floor(ms / 3600000);    const m = Math.floor((ms % 3600000) / 60000);    const s = Math.floor((ms % 60000) / 1000);    return `${pad(h)}:${pad(m)}:${pad(s)}`;  };  const hm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;  const lines = ["# Timelite Log", ""];  for (const key of sortedKeys) {    const group = groups.get(key);    const dayLabel = group.date.toLocaleDateString(undefined, {      weekday: "long",      year: "numeric",      month: "long",      day: "numeric",    });    const total = group.entries.reduce(      (sum, e) => sum + (+new Date(e.end) - +new Date(e.start)),      0    );    lines.push(`## ${dayLabel} — ${hms(total)}`);    lines.push("");    for (const e of group.entries) {      const s = e.start instanceof Date ? e.start : new Date(e.start);      const en = e.end instanceof Date ? e.end : new Date(e.end);      const dur = hms(+en - +s);      const note = (e.note || "").trim();      const tags = (e.tags || []).length ? ` *${e.tags.join(", ")}*` : "";      lines.push(`- \`${dur}\` ${hm(s)} → ${hm(en)}${note ? ` — ${note}` : ""}${tags}`);    }    lines.push("");  }  return lines.join("\n");};export const downloadTextFile = (text, filename, mime) => {  const blob = new Blob([text], { type: mime });  const url = URL.createObjectURL(blob);  const a = document.createElement("a");  a.href = url;  a.download = filename;  document.body.appendChild(a);  a.click();  document.body.removeChild(a);  setTimeout(() => URL.revokeObjectURL(url), 0);};