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>
@@ -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", },});
@@ -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", },});
@@ -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", },});
@@ -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>
@@ -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);};