@@ -16,9 +16,9 @@ https://timelite.bythewood.me/## Why?I want to casually track the time I spend on things without any overhead ofsigning into a service or even being online. Timelite is a progressive web appand works just fine without an internet connection, all data is stored locallyand I don't track anything.signing into a service or even being online. All data stays in your browser(IndexedDB via localForage), there is no backend, and nothing is tracked orsent anywhere.Timelite has been a pretty great companion for me with more aggressive teammanagement software solutions that sometimes don't allow me to swap between
@@ -84,3 +84,7 @@ that you can run:The `up` implies that you want to start the server again, `--build` will rebuildthe container and `-d`, as stated above, starts us in detached mode so you canset it and forget it.If you previously used an older version and now see stale service-worker /filesystem errors in the console, the app now unregisters any old serviceworkers on load. A hard refresh (Ctrl+Shift+R) will clear them after one visit.
@@ -16,7 +16,6 @@ "react-localization": "^2.0.6", "react-toastify": "^11.0.5", "react-transition-group": "^4.2.1", "uuid": "^13.0.0", }, }, },
@@ -167,8 +166,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], }}
modified
components/HotKeysMapping.js
@@ -36,6 +36,8 @@ const HotKeysMapping = (props) => { const router = useRouter(); const onLogPage = () => router.pathname === "/log"; const handlers = { RESET: () => dispatch({ type: "NEW_TIMER" }), ADD_LOG: (event) => {
@@ -54,45 +56,39 @@ const HotKeysMapping = (props) => { ABOUT_PAGE: () => router.push("/about"), SUMMARY_PAGE: () => router.push("/summary"), CLEAR_LOG: () => { dispatch({ type: "CLEAR_LOG" }); if (state.log.length === 0) return; contextStrings.setLanguage(state.language); if (!window.confirm(contextStrings.confirmClearLog)) return; dispatch({ type: "CLEAR_LOG" }); toast.error(contextStrings.resetLog); }, LOG_NEXT: (event) => { event.preventDefault(); if ( window.location.href.substr(window.location.href.length - 3) == "log" ) { dispatch({ type: "LOG_EDIT_TOGLE", edit: false }); if (onLogPage()) { dispatch({ type: "TOGGLE_EDITION", edit: false }); dispatch({ type: "NEXT_LOG_ITEM" }); } }, LOG_PREVIOUS: (event) => { event.preventDefault(); if ( window.location.href.substr(window.location.href.length - 3) == "log" ) { dispatch({ type: "LOG_EDIT_TOGLE", edit: false }); if (onLogPage()) { dispatch({ type: "TOGGLE_EDITION", edit: false }); dispatch({ type: "PREVIOUS_LOG_ITEM" }); } }, LOG_EDIT: (event) => { event.preventDefault(); if (!state.logSelectedEntry) return; if (window.location.href.substr(window.location.href.length - 3) == "log") dispatch({ type: "TOGGLE_EDITION", edit: true }); if (onLogPage()) dispatch({ type: "TOGGLE_EDITION", edit: true }); }, LOG_DELETE_SINGLE: (event) => { event.preventDefault(); if (!state.logSelectedEntry) return; if ( window.location.href.substr(window.location.href.length - 3) == "log" ) { dispatch({ type: "REMOVE_LOG" }); contextStrings.setLanguage(state.language); toast.error(contextStrings.deletedEntry); } if (!onLogPage()) return; contextStrings.setLanguage(state.language); if (!window.confirm(contextStrings.confirmDeleteEntry)) return; dispatch({ type: "REMOVE_LOG" }); toast.error(contextStrings.deletedEntry); }, };
modified
components/context.js
@@ -2,9 +2,11 @@ import React, { useEffect } from "react";import { useReducer, createContext } from "react";import PropTypes from "prop-types";import localForage from "localforage";import { v4 as uuid } from "uuid";import strings from "../l10n/context";const newId = () => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;const initialState = { note: "",
@@ -20,11 +22,8 @@ const Context = createContext();const reducer = (state, action) => { let newState = {}; strings.setLanguage(state.language); switch (action.type) { case "LOCALDATA_READY": strings.setLanguage(action.localdata.language); return { ...action.localdata }; case "SET_LANGUAGE": newState = {
@@ -53,16 +52,14 @@ const reducer = (state, action) => { timer: new Date(), log: [ { id: uuid(), id: newId(), start: state.timer, end: new Date(), note: state.note, tags: state.note .split(" ") .filter((word) => word.startsWith("#")) .map((word) => { return word.toLowerCase(); }), .split(/\s+/) .filter((word) => word.startsWith("#") && word.length > 1) .map((word) => word.toLowerCase()), }, ...state.log, ],
@@ -162,7 +159,6 @@ const ContextProvider = ({ children }) => { const value = { state, dispatch }; useEffect(() => { // Only use localForage in browser environment to avoid SSR errors if (typeof window !== "undefined") { localForage .getItem("context")
@@ -170,7 +166,6 @@ const ContextProvider = ({ children }) => { if (value !== null) dispatch({ type: "LOCALDATA_READY", localdata: value }); }) // Handle any remaining errors gracefully .catch(() => {}); } }, []);
modified
components/entry.js
@@ -7,149 +7,213 @@ import { Context } from "../components/context";import styles from "../styles/components/entry.module.css";const extractTags = (text) => text .split(/\s+/) .filter((word) => word.startsWith("#") && word.length > 1) .map((word) => word.toLowerCase());const toDatetimeLocal = (date) => { const d = date instanceof Date ? date : new Date(date); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;};const Entry = React.forwardRef( ({ entry, removeEntry, isSelected, style }, forwardedRef) => { const { state, dispatch } = useContext(Context); const { register, handleSubmit } = useForm(); const focusedEntry = useRef(null); const setRefs = useCallback( (node) => { focusedEntry.current = node; if (typeof forwardedRef === "function") { forwardedRef(node); } else if (forwardedRef && "current" in forwardedRef) { forwardedRef.current = node; } }, [forwardedRef] ); const onSubmit = (data) => { dispatch({ type: "EDIT_LOG", entry: { ...entry, note: data.note, tags: data.note .split(" ") .filter((word) => word.startsWith("#")) .map((word) => { return word.toLowerCase(); }), const { state, dispatch } = useContext(Context); const { register, handleSubmit, reset } = useForm({ defaultValues: { note: entry.note, start: toDatetimeLocal(entry.start), end: toDatetimeLocal(entry.end), }, }); dispatch({ type: "TOGGLE_EDITION", edit: false, submited: true }); }; const focusedEntry = useRef(null); const isEditing = state.edit && isSelected == entry.id; useEffect(() => { if (isEditing) { reset({ note: entry.note, start: toDatetimeLocal(entry.start), end: toDatetimeLocal(entry.end), }); } }, [isEditing, entry.id, entry.note, entry.start, entry.end, reset]); useEffect(() => { if (isSelected == entry.id && focusedEntry.current) { focusedEntry.current.focus(); focusedEntry.current.scrollIntoView({ behavior: "smooth" }); const setRefs = useCallback( (node) => { focusedEntry.current = node; if (typeof forwardedRef === "function") { forwardedRef(node); } else if (forwardedRef && "current" in forwardedRef) { forwardedRef.current = node; } }, [forwardedRef] ); const onSubmit = (data) => { const start = new Date(data.start); const end = new Date(data.end); if (isNaN(start) || isNaN(end) || end < start) { dispatch({ type: "TOGGLE_EDITION", edit: false }); return; } dispatch({ type: "EDIT_LOG", entry: { ...entry, start, end, note: data.note, tags: extractTags(data.note), }, }); dispatch({ type: "TOGGLE_EDITION", edit: false }); }; useEffect(() => { if (isSelected == entry.id && focusedEntry.current) { focusedEntry.current.focus(); focusedEntry.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, [isSelected, entry.id]); const containerClasses = [styles.entryContainer]; if (isSelected == entry.id) containerClasses.push(styles.selected); if (isEditing) { containerClasses.push(styles.zoom); containerClasses.push(styles.editing); } }); const highlight = isSelected == entry.id ? { filter: "invert(1)" } : {}; const combinedStyle = { ...(style || {}), ...highlight }; const containerClasses = [styles.entryContainer]; if (state.edit) containerClasses.push(styles.zoom); return ( <div style={combinedStyle} className={containerClasses.join(" ")} ref={setRefs} tabIndex={-1} > {state.edit && isSelected == entry.id ? ( <form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}> <div className={styles.entryTime}> {timeString(entry.end - entry.start)} <span>{entry.start.toLocaleTimeString()}</span> </div> <div className={styles.entryNote}> <input {...register("note")} className={styles.entryNoteInput} name="note" autoFocus value={state.log.find((x) => x.id == entry.id).note || ""} onChange={(e) => dispatch({ type: "EDIT_LOG", entry: { ...entry, note: e.target.value, tags: e.target.value .split(" ") .filter((word) => word.startsWith("#")) .map((word) => { return word.toLowerCase(); }), }, }) } /> </div> <button className={`${styles.entryButton} ${styles.entrySubmit}`} type="submit" > ✔ </button> <button className={`${styles.entryButton} ${styles.entryRemove}`} type="button" onClick={() => dispatch({ type: "TOGGLE_EDITION", edit: false })} > x </button> </form> ) : ( <> <div className={styles.entryTime}> {timeString(entry.end - entry.start)} <span>{entry.start.toLocaleTimeString()}</span> </div> <div className={`${styles.entryNote} ${entry.note.length === 0 ? styles.entryNoteEmpty : ""}`.trim()} > {entry.note} {entry.tags.length > 0 && ( <small> {entry.tags .map((tag) => { return tag; }) .join(", ")} </small> )} </div> <button className={`${styles.entryButton} ${styles.entryEdit}`} onClick={() => { dispatch({ type: "SELECT_LOG_ITEM", id: entry.id }); dispatch({ type: "TOGGLE_EDITION", edit: true }); }} > _ </button> <button className={`${styles.entryButton} ${styles.entryRemove}`} onClick={() => { dispatch({ type: "SELECT_LOG_ITEM", id: "" }); removeEntry(entry.id); }} > x </button> </> )} </div> ); return ( <div style={style} className={containerClasses.join(" ")} ref={setRefs} tabIndex={-1} > {isEditing ? ( <form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}> <div className={`${styles.entryTime} ${styles.entryTimeEditing}`}> <div className={styles.entryDuration}> {timeString(entry.end - entry.start)} </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}> <input {...register("note")} className={styles.entryNoteInput} autoFocus aria-label="Note" placeholder="Note with #tags" /> </div> <button className={`${styles.entryButton} ${styles.entrySubmit}`} type="submit" aria-label="Save" title="Save (Enter)" > <IconCheck /> </button> <button className={`${styles.entryButton} ${styles.entryRemove}`} type="button" aria-label="Cancel" title="Cancel" onClick={() => dispatch({ type: "TOGGLE_EDITION", edit: false })} > <IconX /> </button> </form> ) : ( <> <div className={styles.entryTime}> {timeString(entry.end - entry.start)} <span>{entry.start.toLocaleTimeString()}</span> </div> <div className={`${styles.entryNote} ${entry.note.length === 0 ? styles.entryNoteEmpty : ""}`.trim()} > {entry.note} {entry.tags.length > 0 && ( <small>{entry.tags.join(", ")}</small> )} </div> <button className={`${styles.entryButton} ${styles.entryEdit}`} aria-label="Edit" onClick={() => { dispatch({ type: "SELECT_LOG_ITEM", id: entry.id }); dispatch({ type: "TOGGLE_EDITION", edit: true }); }} > <IconPencil /> </button> <button className={`${styles.entryButton} ${styles.entryRemove}`} aria-label="Delete" onClick={() => { dispatch({ type: "SELECT_LOG_ITEM", id: "" }); removeEntry(entry.id); }} > <IconTrash /> </button> </> )} </div> ); });const IconPencil = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/> </svg>);const IconTrash = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/> </svg>);const IconCheck = () => ( <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>);const IconX = () => ( <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>);Entry.propTypes = { entry: PropTypes.object, removeEntry: PropTypes.func,
modified
components/sidebar.js
@@ -14,7 +14,23 @@ const Sidebar = () => { return ( <aside className={styles.side}> <div className={styles.title}>{strings.name}</div> <Link href="/" className={styles.brand} aria-label={strings.name}> <span className={styles.brandMark} aria-hidden="true"> <svg viewBox="0 0 24 24" width="22" height="22"> <circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.9" /> <circle cx="12" cy="12" r="4" fill="currentColor" /> </svg> </span> <span className={styles.brandText}>{strings.name}</span> </Link> <div className={styles.pages}> <Link href="/"
@@ -47,72 +63,67 @@ const Sidebar = () => {export default Sidebar;const HelpIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 0 24 24" width="22" fill="currentColor" aria-hidden="true" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z" /> </svg>);const HelpIcon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z" /> </svg> );};const TimerIcon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M15.07 1.01h-6v2h6v-2zm-4 13h2v-6h-2v6zm8.03-6.62l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.14 4.74 14.19 4 12.07 4c-4.97 0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.11-.74-4.06-1.97-5.61zm-7.03 12.62c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z" /> </svg> );};const TimerIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 0 24 24" width="22" fill="currentColor" aria-hidden="true" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M15.07 1.01h-6v2h6v-2zm-4 13h2v-6h-2v6zm8.03-6.62l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.14 4.74 14.19 4 12.07 4c-4.97 0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.11-.74-4.06-1.97-5.61zm-7.03 12.62c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z" /> </svg>);const LogIcon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" /> </svg> );};const LogIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 0 24 24" width="22" fill="currentColor" aria-hidden="true" > <path d="M0 0h24v24H0V0z" fill="none" /> <path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" /> </svg>);const SummaryIcon = () => { return ( <svg xmlns="http://www.w3.org/2000/svg" enableBackground="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000" >const SummaryIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" enableBackground="new 0 0 24 24" height="22" viewBox="0 0 24 24" width="22" fill="currentColor" aria-hidden="true" > <g> <rect fill="none" height="24" width="24" /> <g> <rect fill="none" height="24" width="24" /> <g> <path d="M19,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M19,19H5V5h14V19z" /> <rect height="5" width="2" x="7" y="12" /> <rect height="10" width="2" x="15" y="7" /> <rect height="3" width="2" x="11" y="14" /> <rect height="2" width="2" x="11" y="10" /> </g> <path d="M19,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M19,19H5V5h14V19z" /> <rect height="5" width="2" x="7" y="12" /> <rect height="10" width="2" x="15" y="7" /> <rect height="3" width="2" x="11" y="14" /> <rect height="2" width="2" x="11" y="10" /> </g> </svg> );}; </g> </svg>);
@@ -8,10 +8,12 @@ const strings = new LocalizedStrings({ sectionTimer: "Timer", sectionNavigation: "Navigation", sectionLog: "Log", sectionLanguage: "Language", keyReset: "Reset timer", keyAddLog: "Add log entry", keyTimerPage: "Move to timer page", keyLogPage: "Move to log page", keySummaryPage: "Move to summary page", keyAboutPage: "Move to about page", keyNextLogEntry: "Move to next log entry", keyPreviousLogEntry: "Move to previous log entry",
@@ -26,10 +28,12 @@ const strings = new LocalizedStrings({ sectionTimer: "タイマー", sectionNavigation: "ナビゲーション", sectionLog: "ログ", sectionLanguage: "言語", keyReset: "タイマーのリセット", keyAddLog: "ログエントリを追加する", keyTimerPage: "タイマーページに移動", keyLogPage: "ログページに移動", keySummaryPage: "サマリーページに移動", keyAboutPage: "についてページに移動", keyNextLogEntry: "次のログエントリに移動", keyPreviousLogEntry: "前のログエントリに移動します",
@@ -44,10 +48,12 @@ const strings = new LocalizedStrings({ sectionTimer: "Zegar", sectionNavigation: "Nawigacja", sectionLog: "Dziennik", sectionLanguage: "Język", keyReset: "Zerowanie zegara", keyAddLog: "Dodaj wpis do dziennika", keyTimerPage: "Przejdź na stronę zegara", keyLogPage: "Przejdź na stronę dziennika", keySummaryPage: "Przejdź na stronę podsumowania", keyAboutPage: "Przejdź na stronę o timelite", keyNextLogEntry: "Przejdź do następnego wpisu", keyPreviousLogEntry: "Przejdź do poprzedniego wpisu",
@@ -7,6 +7,9 @@ const strings = new LocalizedStrings({ editedEntry: "You've edited an entry.", deletedEntry: "You've deleted an entry.", resetLog: "You've reset your log.", confirmClearLog: "Clear all log entries? This cannot be undone.", confirmClearTag: "Delete all entries with this tag? This cannot be undone.", confirmDeleteEntry: "Delete this entry?", }, jp: { loaded: "ローカルストレージから状態をロードしました。",
@@ -14,6 +17,9 @@ const strings = new LocalizedStrings({ editedEntry: "エントリを編集しました。", deletedEntry: "エントリを削除しました。", resetLog: "ログをリセットしました。", confirmClearLog: "すべてのログエントリを削除しますか?元に戻せません。", confirmClearTag: "このタグのすべてのエントリを削除しますか?元に戻せません。", confirmDeleteEntry: "このエントリを削除しますか?", }, pl: { loaded: "Wczytano stan z lokalnego zapisu.",
@@ -21,6 +27,9 @@ const strings = new LocalizedStrings({ editedEntry: "Zmieniłeś wpis.", deletedEntry: "Skasowałeś wpis.", resetLog: "Wyczyściłeś dziennik.", confirmClearLog: "Usunąć wszystkie wpisy dziennika? Tej operacji nie można cofnąć.", confirmClearTag: "Usunąć wszystkie wpisy z tym tagiem? Tej operacji nie można cofnąć.", confirmDeleteEntry: "Usunąć ten wpis?", },});
@@ -2,34 +2,46 @@ import LocalizedStrings from "react-localization";const strings = new LocalizedStrings({ en: { pageTitle: "Log", total: "Total", subtotal: "Subtotal", start: "Start Time", entries: "Entries", nothing: "Your log is empty.", emptyHint: "Add a tagged note from the timer page to see entries here.", nothingFiltered: "No entries match this tag.", tags: "Tags", show: "Show All", clear: "Clear", export: "Export", export: "Export CSV", }, jp: { pageTitle: "ログ", total: "合計", subtotal: "小計", start: "開始", entries: "エントリ数", nothing: "ログは空です。", emptyHint: "タイマーページからタグ付きメモを追加するとここに表示されます。", nothingFiltered: "このタグに一致するエントリはありません。", tags: "タグ", show: "すべて", clear: "すべてクリア", export: "書き出す", export: "CSVに書き出す", }, pl: { pageTitle: "Dziennik", total: "Razem", subtotal: "Podsuma", start: "Czas początkowy", entries: "Wpisy", nothing: "Twój dziennik nie ma wpisów.", emptyHint: "Dodaj notatkę z tagiem ze strony zegara, aby zobaczyć wpisy.", nothingFiltered: "Brak wpisów z tym tagiem.", tags: "Tagi", show: "Pokaż wszystko", clear: "Wyczyść", export: "Wyeksportuj", export: "Eksportuj CSV", },});
@@ -2,28 +2,61 @@ import LocalizedStrings from "react-localization";const strings = new LocalizedStrings({ en: { title: "Summary", totalHours: "Total number of hours spent across all tags.", varHours: (n) => `${n} hours`, tagHours: "The number of hours you've spent per-tag.", numHours: "# of hours", logEmpty: "Your log is empty.", pageTitle: "Summary", empty: "Your log is empty.", emptyHint: "Start the timer and add a tagged note to see your stats aggregate here.", // Metric tile labels statTotal: "Total Hours", statEntries: "Entries", statToday: "Today", statWeek: "This Week", statLongest: "Longest Session", statTags: "Unique Tags", // Section labels sectionPerTag: "Hours per tag", sectionPerDay: "Hours per day · last 14 days", // Chart legend numHours: "hours", // Unit suffixes / fallback hoursSuffix: "h", minsSuffix: "m", none: "—", }, jp: { title: "概要", totalHours: "すべてのタグで費やされた合計時間数。", varHours: (n) => `${n} 時間`, tagHours: "タグごとに費やした時間数。", numHours: "時間数", logEmpty: "ログは空です。", pageTitle: "概要", empty: "ログは空です。", emptyHint: "タイマーを開始してタグ付きメモを追加すると、統計がここに表示されます。", statTotal: "合計時間", statEntries: "エントリ数", statToday: "今日", statWeek: "今週", statLongest: "最長セッション", statTags: "タグ数", sectionPerTag: "タグごとの時間", sectionPerDay: "日ごとの時間 · 過去14日間", numHours: "時間", hoursSuffix: "時", minsSuffix: "分", none: "—", }, pl: { title: "Streszczenie", totalHours: "Całkowita liczba godzin spędzonych na wszystkich tagach.", varHours: (n) => `${n} godzin`, tagHours: "Liczba godzin spędzonych na tagu.", numHours: "Liczba godzin", logEmpty: "Twój dziennik nie ma wpisów.", pageTitle: "Streszczenie", empty: "Twój dziennik nie ma wpisów.", emptyHint: "Uruchom zegar i dodaj notatkę z tagiem, aby zobaczyć tutaj statystyki.", statTotal: "Razem godzin", statEntries: "Wpisy", statToday: "Dzisiaj", statWeek: "Ten tydzień", statLongest: "Najdłuższa sesja", statTags: "Unikalne tagi", sectionPerTag: "Godziny na tag", sectionPerDay: "Godziny na dzień · ostatnie 14 dni", numHours: "godzin", hoursSuffix: "g", minsSuffix: "m", none: "—", },});
@@ -1,7 +1,40 @@/** @type {import('next').NextConfig} */const securityHeaders = [ { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", }, { key: "Content-Security-Policy", value: [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' blob:", "manifest-src 'self'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'", ].join("; "), },];const nextConfig = { devIndicators: { position: 'top-right', position: "top-right", }, async headers() { return [ { source: "/:path*", headers: securityHeaders, }, ]; },};
@@ -17,7 +17,6 @@ "react-is": "^19.2.4", "react-localization": "^2.0.6", "react-toastify": "^11.0.5", "react-transition-group": "^4.2.1", "uuid": "^13.0.0" "react-transition-group": "^4.2.1" }}
@@ -1,12 +1,11 @@import React from "react";import React, { useEffect } from "react";import { TransitionGroup, CSSTransition } from "react-transition-group";import { ToastContainer, toast } from "react-toastify";import { ToastContainer } from "react-toastify";import "react-toastify/dist/ReactToastify.css";import { ContextProvider } from "../components/context";import HotKeysMapping from "../components/HotKeysMapping";import L10n from "../components/l10n";import Sidebar from "../components/sidebar";import "../styles/globals.css";
@@ -14,11 +13,26 @@ import "../styles/globals.css";const MyApp = ({ Component, pageProps, router }) => { const pageNodeRef = React.useMemo(() => React.createRef(), [router.route]); // Clean up any stale service worker registered by the old Workbox sw.js // that used to ship with this project. Without this, browsers that // visited an earlier version keep a dead SW cached and log filesystem // / precache errors on every load. useEffect(() => { if (typeof window === "undefined" || !("serviceWorker" in navigator)) return; navigator.serviceWorker .getRegistrations() .then((regs) => regs.forEach((r) => r.unregister())) .catch(() => {}); if (window.caches && caches.keys) { caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {}); } }, []); return ( <ContextProvider> <HotKeysMapping> <ToastContainer position="top-right" /> <L10n /> <ToastContainer position="top-right" /> <TransitionGroup component={null}> <CSSTransition key={router.route}
modified
pages/_document.js
@@ -10,8 +10,18 @@ class MyDocument extends Document { name="description" content="Why is it 5 AM? Isn't there something simple I can use to track what I'm doing with all this time?" /> <meta name="theme-color" content="#0D0221" /> <meta name="theme-color" content="#0E0D0A" /> <link rel="manifest" href="/manifest.json" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" /> </Head> <body> <Main />
@@ -1,6 +1,7 @@import React, { useContext } from "react";import Page from "../components/page";import L10n from "../components/l10n";import { Context } from "../components/context";import strings from "../l10n/about";
@@ -22,20 +23,21 @@ const About = () => { <div className={styles.githubLogo}> <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" fill="white" className="bi bi-github" width="32" height="32" fill="currentColor" viewBox="0 0 16 16" > <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z" /> </svg> </div> </a> <div className={styles.grid}> <main className={styles.main}> <h1 className={styles.heading}>{strings.title}</h1> <blockquote className={styles.blockquote}>{strings.quote}</blockquote> <div className="page-grid"> <main className="page-main"> <h1 className="page-title">{strings.title}</h1> <blockquote className={styles.blockquote}> {strings.quote} </blockquote> <a className={styles.creator} href="https://www.isaacbythewood.com/"
@@ -46,26 +48,29 @@ const About = () => { </a> <div className={styles.descriptionContainer}> <div className={styles.keysColumn}> <h2 className={styles.sectionTitle}>{strings.sectionTimer}</h2> <h2 className="section-label">{strings.sectionTimer}</h2> <div className={styles.keysDescription}> <span>⎇+r</span> {strings.keyReset} </div> <div className={styles.keysDescription}> <span>⎇+a</span> {strings.keyAddLog} </div> <h2 className={styles.sectionTitle}>{strings.sectionNavigation}</h2> <h2 className="section-label">{strings.sectionNavigation}</h2> <div className={styles.keysDescription}> <span>⎇+t</span> {strings.keyTimerPage} </div> <div className={styles.keysDescription}> <span>⎇+l</span> {strings.keyLogPage} </div> <div className={styles.keysDescription}> <span>⎇+s</span> {strings.keySummaryPage} </div> <div className={styles.keysDescription}> <span>⎇+o</span> {strings.keyAboutPage} </div> </div> <div className={styles.keysColumn}> <h2 className={styles.sectionTitle}>{strings.sectionLog}</h2> <h2 className="section-label">{strings.sectionLog}</h2> <div className={styles.keysDescription}> <span>↓</span> {strings.keyNextLogEntry} </div>
@@ -83,6 +88,11 @@ const About = () => { </div> </div> </div> <h2 className="section-label">{strings.sectionLanguage}</h2> <div className={styles.languageRow}> <L10n /> </div> </main> </div> </Page>
@@ -1,4 +1,4 @@import React, { useContext, useState, useRef } from "react";import React, { useContext, useState, useRef, useMemo } from "react";import { TransitionGroup, CSSTransition } from "react-transition-group";import { CSVLink } from "react-csv";import { toast } from "react-toastify";
@@ -12,6 +12,22 @@ import { timeString } from "../utils/time";import styles from "../styles/pages/log.module.css";const CSV_HEADERS = [ { label: "id", key: "id" }, { label: "start", key: "start" }, { label: "end", key: "end" }, { label: "duration_seconds", key: "duration_seconds" }, { label: "note", key: "note" }, { label: "tags", key: "tags" },];const sanitizeCell = (value) => { if (value == null) return ""; const str = String(value); if (/^[=+\-@\t\r]/.test(str)) return `'${str}`; return str;};const Log = () => { const { state, dispatch } = useContext(Context); const [filter, setFilter] = useState({ type: "SHOW_ALL" });
@@ -48,59 +64,119 @@ const Log = () => { }, 0); }; const csvData = useMemo( () => state.log.map((entry) => { const start = entry.start instanceof Date ? entry.start : new Date(entry.start); const end = entry.end instanceof Date ? entry.end : new Date(entry.end); return { id: entry.id, start: start.toISOString(), end: end.toISOString(), duration_seconds: Math.round((end - start) / 1000), note: sanitizeCell(entry.note), tags: sanitizeCell((entry.tags || []).join(" ")), }; }), [state.log] ); const removeEntry = (id) => { contextStrings.setLanguage(state.language); if (!window.confirm(contextStrings.confirmDeleteEntry)) return; if (getVisibleEntries(state.log, filter).length === 1) { setFilter({ type: "SHOW_ALL" }); } dispatch({ type: "REMOVE_LOG", id: id }); toast.error(contextStrings.deletedEntry); }; const clearAll = () => { contextStrings.setLanguage(state.language); if (!window.confirm(contextStrings.confirmClearLog)) return; dispatch({ type: "CLEAR_LOG" }); toast.error(contextStrings.resetLog); }; const clearTag = () => { contextStrings.setLanguage(state.language); if (!window.confirm(contextStrings.confirmClearTag)) return; dispatch({ type: "CLEAR_TAG", tag: filter.tag }); setFilter({ type: "SHOW_ALL" }); toast.error(contextStrings.deletedEntry); }; const visibleEntries = getVisibleEntries(state.log, filter); const isEmpty = state.log.length === 0; if (isEmpty) { return ( <Page title="Log"> <div className="page-grid"> <main className="page-main"> <h1 className="page-title">{strings.pageTitle}</h1> </main> </div> <div className="empty-state"> <p className="empty-state-title">{strings.nothing}</p> <p className="empty-state-hint">{strings.emptyHint}</p> </div> </Page> ); } const startTimeLabel = state.log[state.log.length - 1].start.toLocaleTimeString(); return ( <Page title="Log"> <div className={styles.grid}> {state.log.length > 0 && ( <aside className={styles.details}> <div className={styles.total}> <span>{strings.start}</span> {state.log[state.log.length - 1].start.toLocaleTimeString()} <div className="page-grid"> <main className="page-main"> <h1 className="page-title">{strings.pageTitle}</h1> <div className={styles.tiles}> <div className={styles.tile}> <span className={styles.tileLabel}>{strings.start}</span> <span className={styles.tileValue}>{startTimeLabel}</span> </div> <div className={styles.total}> <span>{strings.subtotal}</span> {timeString(getVisibleTotalMilliseconds())} <div className={styles.tile}> <span className={styles.tileLabel}>{strings.subtotal}</span> <span className={styles.tileValue}> {timeString(getVisibleTotalMilliseconds())} </span> </div> <div className={styles.total}> <span>{strings.total}</span> {timeString(getTotalMilliseconds())} <div className={styles.tile}> <span className={styles.tileLabel}>{strings.total}</span> <span className={styles.tileValue}> {timeString(getTotalMilliseconds())} </span> </div> <div className={styles.tile}> <span className={styles.tileLabel}>{strings.entries}</span> <span className={styles.tileValue}>{state.log.length}</span> </div> <CSVLink className={styles.csvButton} data={state.log} filename="timelite-export.csv" className={styles.tileExport} data={csvData} headers={CSV_HEADERS} filename={`timelite-export-${new Date() .toISOString() .slice(0, 10)}.csv`} > {strings.export} <span className={styles.tileLabel}>{strings.export}</span> <span className={styles.tileExportGlyph}>↓</span> </CSVLink> </aside> )} <main className={`${styles.main} ${ getVisibleEntries(state.log, filter).length === 0 ? styles.mainEmpty : "" }`.trim()} tabIndex="1" > </div> {getTags(state.log).length > 0 && ( <div className={styles.topBar}> <div className={styles.filters}> <span>{strings.tags}</span> <span className={styles.filtersLabel}>{strings.tags}</span> {getTags(state.log).map((tag) => { const active = filter.type === "SHOW_TAG" && filter.tag === tag; return ( <button className={styles.filterButton} className={`${styles.filterButton} ${active ? styles.filterButtonActive : ""}`.trim()} key={tag} onClick={() => setFilter({ type: "SHOW_TAG", tag: tag })} >
@@ -108,72 +184,60 @@ const Log = () => { </button> ); })} <button className={styles.filterButton} onClick={() => setFilter({ type: "SHOW_ALL" })} > {strings.show} </button> {filter.type === "SHOW_TAG" && ( <button className={styles.filterButton} onClick={() => setFilter({ type: "SHOW_ALL" })} > {strings.show} </button> )} </div> {filter.tag ? ( <button className={styles.reset} onClick={() => { dispatch({ type: "CLEAR_TAG", tag: filter.tag }); setFilter({ type: "SHOW_ALL" }); contextStrings.setLanguage(state.language); toast.error(contextStrings.deletedEntry); }} > <button className={styles.reset} onClick={clearTag}> {strings.clear} {filter.tag} </button> ) : ( <button className={styles.reset} onClick={() => { dispatch({ type: "CLEAR_LOG" }); contextStrings.setLanguage(state.language); toast.error(contextStrings.resetLog); }} > <button className={styles.reset} onClick={clearAll}> {strings.clear} </button> )} </div> )} {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> </> <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> ) : ( <div className={styles.nothing}>{strings.nothing}</div> <div className={styles.nothingFiltered}> {strings.nothingFiltered} </div> )} </main> </div>
modified
pages/summary.js
@@ -1,4 +1,4 @@import React, { useEffect, useRef, useContext } from "react";import React, { useEffect, useRef, useContext, useMemo } from "react";import Chart from "chart.js/auto";import Page from "../components/page";
@@ -7,99 +7,291 @@ import strings from "../l10n/summary";import styles from "../styles/pages/summary.module.css";const PALETTE = [ "#6B9E78", "#C9A84C", "#C47055", "#7EAAB8", "#7DB88C", "#DDC06A",];const MS = { minute: 60 * 1000, hour: 60 * 60 * 1000, day: 24 * 60 * 60 * 1000,};const formatDuration = (ms, s) => { if (!ms) return "0" + s.hoursSuffix; const hours = ms / MS.hour; if (hours >= 1) return `${hours.toFixed(hours >= 10 ? 0 : 1)}${s.hoursSuffix}`; const mins = Math.round(ms / MS.minute); return `${mins}${s.minsSuffix}`;};const formatHours = (ms) => Math.round((ms / MS.hour) * 100) / 100;const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x;};const Summary = () => { const { state } = useContext(Context); strings.setLanguage(state.language); const canvasRef = useRef(null); const getLabels = (entries) => { let tags = []; entries.map((entry) => tags.push(...entry.tags)); return [...new Set(tags)]; }; const getDatasets = (entries) => { const labels = getLabels(entries); let datasets = []; labels.map((label) => { const labeledEntries = [ ...entries.filter((entry) => entry.tags.includes(label)), ]; let totalTime = 0; labeledEntries.map((entry) => { totalTime += entry.end - entry.start; }); datasets.push(totalTime / 1000 / 60 / 60); }); return datasets; }; const getTotalTime = (entries) => { let totalTime = 0; entries.map((entry) => { totalTime += entry.end - entry.start; const tagCanvasRef = useRef(null); const dayCanvasRef = useRef(null); const log = state.log; const hasData = log.length > 0; // Derived stats ---------------------------------------------------------- const stats = useMemo(() => { if (!hasData) return null; const now = Date.now(); const today = startOfDay(now).getTime(); const weekAgo = today - 6 * MS.day; let totalMs = 0; let todayMs = 0; let weekMs = 0; let longestMs = 0; const tagSet = new Set(); for (const e of log) { const start = +new Date(e.start); const end = +new Date(e.end); const dur = end - start; totalMs += dur; if (dur > longestMs) longestMs = dur; if (start >= today) todayMs += dur; if (start >= weekAgo) weekMs += dur; for (const t of e.tags || []) tagSet.add(t); } return { totalMs, entryCount: log.length, todayMs, weekMs, longestMs, tagCount: tagSet.size, }; }, [log, hasData]); // Hours per tag const perTag = useMemo(() => { if (!hasData) return { labels: [], data: [] }; const map = new Map(); for (const e of log) { const dur = +new Date(e.end) - +new Date(e.start); for (const t of e.tags || []) { map.set(t, (map.get(t) || 0) + dur); } } const entries = [...map.entries()].sort((a, b) => b[1] - a[1]); return { labels: entries.map(([t]) => t), data: entries.map(([, ms]) => formatHours(ms)), }; }, [log, hasData]); // Hours per day over last 14 days const perDay = useMemo(() => { if (!hasData) return { labels: [], data: [] }; const days = 14; const todayStart = startOfDay(Date.now()).getTime(); const buckets = new Array(days).fill(0); const labels = new Array(days).fill(0).map((_, i) => { const d = new Date(todayStart - (days - 1 - i) * MS.day); return `${d.getMonth() + 1}/${d.getDate()}`; }); totalTime = totalTime / 1000 / 60 / 60; return Math.round(totalTime * 100) / 100; }; for (const e of log) { const start = +new Date(e.start); const daysAgo = Math.floor((todayStart - startOfDay(start).getTime()) / MS.day); if (daysAgo < 0 || daysAgo >= days) continue; const dur = +new Date(e.end) - start; buckets[days - 1 - daysAgo] += dur; } return { labels, data: buckets.map((ms) => formatHours(ms)) }; }, [log, hasData]); // Chart: hours per tag --------------------------------------------------- useEffect(() => { if (state.log.length <= 0) return; const chart = new Chart(canvasRef.current, { if (!hasData || !tagCanvasRef.current) return; const chart = new Chart(tagCanvasRef.current, { type: "bar", data: { labels: getLabels(state.log), labels: perTag.labels, datasets: [ { label: strings.numHours, data: perTag.data, backgroundColor: perTag.labels.map( (_, i) => PALETTE[i % PALETTE.length] + "cc" ), borderColor: perTag.labels.map( (_, i) => PALETTE[i % PALETTE.length] ), borderWidth: 1, borderRadius: 2, }, ], }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false }, tooltip: { backgroundColor: "#13120e", borderColor: "rgba(107,158,120,0.3)", borderWidth: 1, titleFont: { family: "JetBrains Mono, monospace" }, bodyFont: { family: "JetBrains Mono, monospace" }, }, }, scales: { x: { ticks: { color: "#a09890", font: { family: "JetBrains Mono, monospace", size: 11 }, }, grid: { color: "rgba(221,215,205,0.04)" }, }, y: { ticks: { color: "#a09890", font: { family: "JetBrains Mono, monospace", size: 11 }, }, grid: { color: "rgba(221,215,205,0.04)" }, }, }, }, }); return () => chart.destroy(); }, [perTag, hasData, state.language]); // Chart: hours per day --------------------------------------------------- useEffect(() => { if (!hasData || !dayCanvasRef.current) return; const chart = new Chart(dayCanvasRef.current, { type: "line", data: { labels: perDay.labels, datasets: [ { label: strings.numHours, data: getDatasets(state.log), backgroundColor: [ "rgba(255, 99, 132, 0.5)", "rgba(54, 162, 235, 0.7)", "rgba(255, 206, 86, 0.7)", "rgba(75, 192, 192, 0.7)", "rgba(153, 102, 255, 0.7)", "rgba(255, 159, 64, 0.7)", ], data: perDay.data, fill: true, tension: 0.35, backgroundColor: "rgba(107,158,120,0.14)", borderColor: "#6B9E78", borderWidth: 1.5, pointBackgroundColor: "#7DB88C", pointBorderColor: "#0e0d0a", pointRadius: 3, pointHoverRadius: 5, }, ], }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false }, tooltip: { backgroundColor: "#13120e", borderColor: "rgba(107,158,120,0.3)", borderWidth: 1, titleFont: { family: "JetBrains Mono, monospace" }, bodyFont: { family: "JetBrains Mono, monospace" }, }, }, scales: { x: { ticks: { color: "#a09890", font: { family: "JetBrains Mono, monospace", size: 10 }, }, grid: { color: "rgba(221,215,205,0.04)" }, }, y: { ticks: { color: "#a09890", font: { family: "JetBrains Mono, monospace", size: 10 }, }, grid: { color: "rgba(221,215,205,0.04)" }, beginAtZero: true, }, }, }, }); return () => chart.destroy(); }, [perDay, hasData, state.language]); return () => { chart.destroy(); }; }, [state.language]); if (!hasData) { return ( <Page title="Summary"> <div className="page-grid"> <main className="page-main"> <h1 className="page-title">{strings.pageTitle}</h1> </main> </div> <div className="empty-state"> <p className="empty-state-title">{strings.empty}</p> <p className="empty-state-hint">{strings.emptyHint}</p> </div> </Page> ); } return ( <Page title="Summary"> <div className={styles.grid}> <main className={`${styles.main} ${ state.log.length <= 0 ? styles.mainEmpty : "" }`.trim()} tabIndex="1" > {state.log.length > 0 ? ( <> <h1 className={styles.title}>{strings.title}</h1> <p>{strings.totalHours}</p> <div className={styles.totalTime}> {strings.varHours(getTotalTime(state.log))} </div> <p>{strings.tagHours}</p> <div className={styles.canvasWrapper}> <canvas ref={canvasRef} /> </div> </> ) : ( <div className={styles.nothing}>{strings.logEmpty}</div> )} <div className="page-grid"> <main className="page-main"> <h1 className="page-title">{strings.pageTitle}</h1> <div className={styles.tiles}> <Tile label={strings.statTotal}> {formatDuration(stats.totalMs, strings)} </Tile> <Tile label={strings.statToday}> {formatDuration(stats.todayMs, strings)} </Tile> <Tile label={strings.statWeek}> {formatDuration(stats.weekMs, strings)} </Tile> <Tile label={strings.statLongest}> {formatDuration(stats.longestMs, strings)} </Tile> <Tile label={strings.statEntries}>{stats.entryCount}</Tile> <Tile label={strings.statTags}>{stats.tagCount}</Tile> </div> <h2 className="section-label">{strings.sectionPerTag}</h2> <div className={styles.chartCard}> <canvas ref={tagCanvasRef} /> </div> <h2 className="section-label">{strings.sectionPerDay}</h2> <div className={styles.chartCard}> <canvas ref={dayCanvasRef} /> </div> </main> </div> </Page> );};const Tile = ({ label, children }) => ( <div className={styles.tile}> <span className={styles.tileLabel}>{label}</span> <span className={styles.tileValue}>{children}</span> </div>);export default Summary;
modified
public/favicon.ico
binary file
modified
public/manifest.json
@@ -1,7 +1,7 @@{ "name": "Timelite", "short_name": "Timelite", "background_color": "#0D0221", "background_color": "#0E0D0A", "display": "standalone", "scope": "/", "start_url": "/",
@@ -12,5 +12,5 @@ "sizes": "512x512" } ], "theme_color": "#0D0221" "theme_color": "#0E0D0A"}
modified
public/static/logo.png
binary file
@@ -1 +0,0 @@if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise((s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()})).then((()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e})));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map((e=>o[e]||r(e)))).then((e=>(a(...e),i)))}}define(["./workbox-1bb06f5e"],(function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/_G91BBERxYXwD1nJDaiiR/_buildManifest.js",revision:"91e5c64ad0fca6a30720c5df62c7480f"},{url:"/_next/static/_G91BBERxYXwD1nJDaiiR/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/3fff1979-e05f9502cbb9afd3.js",revision:"e05f9502cbb9afd3"},{url:"/_next/static/chunks/57-4ec64d5c3964051e.js",revision:"4ec64d5c3964051e"},{url:"/_next/static/chunks/798-a279a2d3464180db.js",revision:"a279a2d3464180db"},{url:"/_next/static/chunks/framework-02398e00071ab346.js",revision:"02398e00071ab346"},{url:"/_next/static/chunks/main-de624c79bb36ac66.js",revision:"de624c79bb36ac66"},{url:"/_next/static/chunks/pages/_app-b340974d47531072.js",revision:"b340974d47531072"},{url:"/_next/static/chunks/pages/_error-e4f561a102d9bb14.js",revision:"e4f561a102d9bb14"},{url:"/_next/static/chunks/pages/about-f05409bbb33b2c17.js",revision:"f05409bbb33b2c17"},{url:"/_next/static/chunks/pages/index-187063dd54b22a7c.js",revision:"187063dd54b22a7c"},{url:"/_next/static/chunks/pages/log-1e1c99e0b03c2cf5.js",revision:"1e1c99e0b03c2cf5"},{url:"/_next/static/chunks/pages/summary-aff8ab92d9317b65.js",revision:"aff8ab92d9317b65"},{url:"/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js",revision:"837c0df77fd5009c9e46d446188ecfd0"},{url:"/_next/static/chunks/webpack-ee7e63bc15b31913.js",revision:"ee7e63bc15b31913"},{url:"/_next/static/css/00e700f65670fdbd.css",revision:"00e700f65670fdbd"},{url:"/_next/static/css/0b64542f6d0b77e6.css",revision:"0b64542f6d0b77e6"},{url:"/_next/static/css/7c3dc3d95952a61f.css",revision:"7c3dc3d95952a61f"},{url:"/_next/static/css/a669697f4aac1aa0.css",revision:"a669697f4aac1aa0"},{url:"/_next/static/css/a812ef7d01f8018f.css",revision:"a812ef7d01f8018f"},{url:"/favicon.ico",revision:"09e229d772adc57c987768f35b3d4b6a"},{url:"/manifest.json",revision:"fd60d88ae5d9c197f890bc5610fd6249"},{url:"/robots.txt",revision:"1c1bcfe2c83986eeb8df4a6ecd4739fc"},{url:"/sitemap.xml",revision:"ee271b8714d0514d35ad607ec28c2616"},{url:"/static/logo.png",revision:"09e229d772adc57c987768f35b3d4b6a"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute((({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")}),new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute((({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")}),new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute((({url:e})=>!(self.origin===e.origin)),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}));
deleted
public/workbox-1bb06f5e.js
@@ -1 +0,0 @@define(["exports"],(function(t){"use strict";try{self["workbox:core:6.6.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:6.6.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super((({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)}),e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",(t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map((e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})})));t.waitUntil(s),t.ports&&t.ports[0]&&s.then((()=>t.ports[0].postMessage(!0)))}}))}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch((async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n}))),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r((({url:t})=>t.href===s.href),e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:6.6.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter((t=>t&&t.length>0)).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise(((t,e)=>{this.resolve=t,this.reject=e}))}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:r,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,e){const n=m(t);var r;await(r=0,new Promise((t=>setTimeout(t,r))));const i=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=i.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.R(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const r=p(e.url,s);if(e.url===r)return t.match(e,n);const i=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,i);for(const e of a)if(r===p(e.url,s))return t.match(e,n)}(u,i.clone(),["__WB_REVISION__"],h):null;try{await u.put(i,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of g)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:i,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const r=Object.assign(Object.assign({},n),{state:s});return e[t](r)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async R(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class R{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,r=new v(this,{event:e,request:s,params:n}),i=this.q(r,s,e);return[i,this.D(i,r,s,e)]}async q(t,e,n){let r;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(r=await this.U(e,t),!r||"error"===r.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(r=await i({error:s,event:n,request:e}),r)break;if(!r)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))r=await s({event:n,request:e,response:r});return r}async D(t,e,s,n){let r,i;try{r=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:i}),e.destroy(),i)throw i}}function b(t){t.then((()=>{}))}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var s=arguments[e];for(var n in s)({}).hasOwnProperty.call(s,n)&&(t[n]=s[n])}return t},q.apply(null,arguments)}let D,U;const x=new WeakMap,L=new WeakMap,I=new WeakMap,C=new WeakMap,E=new WeakMap;let N={get(t,e,s){if(t instanceof IDBTransaction){if("done"===e)return L.get(t);if("objectStoreNames"===e)return t.objectStoreNames||I.get(t);if("store"===e)return s.objectStoreNames[1]?void 0:s.objectStore(s.objectStoreNames[0])}return k(t[e])},set:(t,e,s)=>(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(B(this),e),k(x.get(this))}:function(...e){return k(t.apply(B(this),e))}:function(e,...s){const n=t.call(B(this),e,...s);return I.set(n,e.sort?e.sort():[e]),k(n)}}function T(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(L.has(t))return;const e=new Promise(((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)}));L.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some((t=>e instanceof t))?new Proxy(t,N):t);var e}function k(t){if(t instanceof IDBRequest)return function(t){const e=new Promise(((e,s)=>{const n=()=>{t.removeEventListener("success",r),t.removeEventListener("error",i)},r=()=>{e(k(t.result)),n()},i=()=>{s(t.error),n()};t.addEventListener("success",r),t.addEventListener("error",i)}));return e.then((e=>{e instanceof IDBCursor&&x.set(e,t)})).catch((()=>{})),E.set(e,t),e}(t);if(C.has(t))return C.get(t);const e=T(t);return e!==t&&(C.set(t,e),E.set(e,t)),e}const B=t=>E.get(t);const P=["get","getKey","getAll","getAllKeys","count"],M=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,r=M.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!r&&!P.includes(s))return;const i=async function(t,...e){const i=this.transaction(t,r?"readwrite":"readonly");let a=i.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),r&&i.done]))[0]};return W.set(e,i),i}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:6.6.0"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.L=t}I(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.I(t),this.L&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",(t=>e(t.oldVersion,t))),k(s).then((()=>{}))}(this.L)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.L,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const r=[];let i=0;for(;n;){const s=n.value;s.cacheName===this.L&&(t&&s.timestamp<t||e&&i>=e?r.push(n.value):i++),n=await n.continue()}const a=[];for(const t of r)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.L+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:r,terminated:i}={}){const a=indexedDB.open(t,e),o=k(a);return n&&a.addEventListener("upgradeneeded",(t=>{n(k(a.result),t.oldVersion,t.newVersion,k(a.transaction),t)})),s&&a.addEventListener("blocked",(t=>s(t.oldVersion,t.newVersion,t))),o.then((t=>{i&&t.addEventListener("close",(()=>i())),r&&t.addEventListener("versionchange",(t=>r(t.oldVersion,t.newVersion,t)))})).catch((()=>{})),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.T=!1,this.k=e.maxEntries,this.B=e.maxAgeSeconds,this.P=e.matchOptions,this.L=t,this.M=new A(t)}async expireEntries(){if(this.O)return void(this.T=!0);this.O=!0;const t=this.B?Date.now()-1e3*this.B:0,e=await this.M.expireEntries(t,this.k),s=await self.caches.open(this.L);for(const t of e)await s.delete(t,this.P);this.O=!1,this.T&&(this.T=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.M.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.B){const e=await this.M.getTimestamp(t),s=Date.now()-1e3*this.B;return void 0===e||e<s}return!1}async delete(){this.T=!1,await this.M.expireEntries(1/0)}}try{self["workbox:range-requests:6.6.0"]&&_()}catch(t){}async function H(t,e){try{if(206===e.status)return e;const n=t.headers.get("range");if(!n)throw new s("no-range-header");const r=function(t){const e=t.trim().toLowerCase();if(!e.startsWith("bytes="))throw new s("unit-must-be-bytes",{normalizedRangeHeader:e});if(e.includes(","))throw new s("single-range-only",{normalizedRangeHeader:e});const n=/(\d*)-(\d*)/.exec(e);if(!n||!n[1]&&!n[2])throw new s("invalid-range-values",{normalizedRangeHeader:e});return{start:""===n[1]?void 0:Number(n[1]),end:""===n[2]?void 0:Number(n[2])}}(n),i=await e.blob(),a=function(t,e,n){const r=t.size;if(n&&n>r||e&&e<0)throw new s("range-not-satisfiable",{size:r,end:n,start:e});let i,a;return void 0!==e&&void 0!==n?(i=e,a=n+1):void 0!==e&&void 0===n?(i=e,a=r):void 0!==n&&void 0===e&&(i=r-n,a=r),{start:i,end:a}}(i,r.start,r.end),o=i.slice(a.start,a.end),c=o.size,h=new Response(o,{status:206,statusText:"Partial Content",headers:e.headers});return h.headers.set("Content-Length",String(c)),h.headers.set("Content-Range",`bytes ${a.start}-${a.end-1}/${i.size}`),h}catch(t){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}function $(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:6.6.0"]&&_()}catch(t){}function z(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const r=new URL(n,location.href),i=new URL(n,location.href);return r.searchParams.set("__WB_REVISION__",e),{cacheKey:r.href,url:i.href}}class G{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function X(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const r=t.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},a=e?e(i):i,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?r.body:await r.blob();return new Response(o,a)}class Y extends R{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(Y.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const r=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=r.integrity,i=t.integrity,a=!i||i===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?i||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==Y.copyRedirectedCacheableResponsesPlugin&&(n===Y.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(Y.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}Y.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},Y.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await X(t):t};class Z{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new Y({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:r}=z(n),i="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(r)&&this.F.get(r)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(r),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:r});this.$.set(t,n.integrity)}if(this.F.set(r,t),this.H.set(r,i),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return $(t,(async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),r=this.H.get(e),i=new Request(e,{integrity:n,cache:r,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:i,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}}))}activate(t){return $(t,(async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const r of e)s.has(r.url)||(await t.delete(r),n.push(r.url));return{deletedURLs:n}}))}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const tt=()=>(Q||(Q=new Z),Q);class et extends r{constructor(t,e){super((({request:s})=>{const n=t.getURLsToCacheKeys();for(const r of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:r}={}){const i=new URL(t,location.href);i.hash="",yield i.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some((t=>t.test(s)))&&t.searchParams.delete(s);return t}(i,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(r){const t=r({url:i});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(r);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}}),t.strategy)}}t.CacheFirst=class extends R{async U(t,e){let n,r=await e.cacheMatch(t);if(!r)try{r=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new s("no-response",{url:t.url,error:n});return r}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const r=this.V(n),i=this.J(s);b(i.expireEntries());const a=i.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return r?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.B=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){g.add(t)}((()=>this.deleteCacheAndMetadata()))}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.B)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.B}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends R{constructor(t={}){super(t),this.plugins.some((t=>"cacheWillUpdate"in t))||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],r=[];let i;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});i=s,r.push(a)}const a=this.st({timeoutId:i,request:t,logs:n,handler:e});r.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(r))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise((e=>{n=setTimeout((async()=>{e(await s.cacheMatch(t))}),1e3*this.tt)})),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let r,i;try{i=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(r=t)}return t&&clearTimeout(t),!r&&i||(i=await n.cacheMatch(e)),i}},t.RangeRequestsPlugin=class{constructor(){this.cachedResponseWillBeUsed=async({request:t,cachedResponse:e})=>e&&t.headers.has("range")?await H(t,e):e}},t.StaleWhileRevalidate=class extends R{constructor(t={}){super(t),this.plugins.some((t=>"cacheWillUpdate"in t))||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch((()=>{}));e.waitUntil(n);let r,i=await e.cacheMatch(t);if(i);else try{i=await n}catch(t){t instanceof Error&&(r=t)}if(!i)throw new s("no-response",{url:t.url,error:r});return i}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",(t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter((s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t));return await Promise.all(s.map((t=>self.caches.delete(t)))),s})(e).then((t=>{})))}))},t.clientsClaim=function(){self.addEventListener("activate",(()=>self.clients.claim()))},t.precacheAndRoute=function(t,e){!function(t){tt().precache(t)}(t),function(t){const e=tt();h(new et(e,t))}(e)},t.registerRoute=h}));
@@ -1,10 +1,13 @@const theme = { colors: { one: "#0D0221", two: "#261447", three: "#D40078", four: "#FF3864", five: "#FF6C11", bg: "#0E0D0A", bgDeep: "#090806", surface: "#13120E", green: "#6B9E78", greenBright: "#7DB88C", amber: "#C9A84C", terracotta: "#C47055", text: "#DDD7CD", }, breakpoint: "max-width: 1023.99px",};
modified
styles/components/entry.module.css
@@ -1,17 +1,34 @@.entryContainer { background-color: #ffffff; color: #000000; margin-bottom: 15px; background-color: var(--surface); color: var(--text); margin-bottom: 10px; display: grid; grid-template-columns: 150px 1fr 50px 50px; align-items: center; border-bottom: 1px solid var(--color-four); transition-duration: 250ms; transition-property: transform; grid-template-columns: 170px 1fr 44px 44px; align-items: stretch; border: 1px solid var(--border); border-radius: var(--radius); transition: border-color 200ms ease, box-shadow 200ms ease, transform 180ms ease; overflow: hidden;}.entryContainer.editing { grid-template-columns: 300px 1fr 44px 44px;}.entryContainer:hover { border-color: var(--border-green);}.selected { border-color: var(--green); box-shadow: 0 0 0 1px var(--green), 0 0 24px var(--green-dim);}.zoom { transform: scale(1.05); transform: scale(1.01);}@media (max-width: 1023.99px) {
@@ -28,43 +45,148 @@.entryNoteInput { border: none; padding: 12px 0; font-size: 1em; padding: 10px 0; font-size: 0.95em; width: 100%; border-bottom: 2px solid var(--color-three); border-bottom: 1px solid var(--border-green); background: transparent; color: inherit; color: var(--text-bright); outline: none; letter-spacing: 0.02em;}.entryNoteInput:focus-visible { border-bottom-color: var(--green); box-shadow: 0 1px 0 0 var(--green);}.entryTime { font-weight: bold; font-size: 1.3em; padding: 15px; font-weight: 600; font-size: 1.1em; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; padding: 12px 14px; height: 100%; background-color: var(--color-four); color: #ffffff; background-color: var(--bg-deep); color: var(--green-bright); text-align: center; display: flex; justify-content: center; align-items: center; box-sizing: border-box; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); min-width: 0;}.entryTimeEditing { align-items: stretch; gap: 8px; text-align: left; padding: 12px;}.entryDuration { font-size: 0.95em; letter-spacing: 0.04em; text-align: center; color: var(--green-bright); font-weight: 700; padding-bottom: 6px; border-bottom: 1px solid var(--border);}.entryTime span { font-size: 0.7em; opacity: 0.8; font-weight: lighter; opacity: 0.7; font-weight: 400; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em;}.entryTimeRow { display: flex; align-items: center; gap: 6px; width: 100%;}.entryTimeRowLabel { font-size: 0.58em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.18em; color: var(--text-muted); min-width: 28px; text-align: right; flex-shrink: 0;}.entryTimeInput { flex: 1 1 auto; font-size: 0.72em; font-family: var(--font-mono); font-weight: 400; padding: 4px 6px; border: 1px solid var(--border-green); background: var(--surface-2); color: var(--text); border-radius: 2px; width: 100%; min-width: 0; box-sizing: border-box; color-scheme: dark;}.entryTimeInput:focus-visible { outline: none; border-color: var(--green); box-shadow: 0 0 0 2px var(--green-dim);}/* Trim and recolor the native calendar picker indicator so the seconds digit never collides with it on Chromium. */.entryTimeInput::-webkit-calendar-picker-indicator { filter: invert(0.55) sepia(0.2) saturate(3) hue-rotate(80deg); padding: 0; margin-left: 4px; cursor: pointer; opacity: 0.6;}.entryTimeInput::-webkit-calendar-picker-indicator:hover { opacity: 1;}@media (max-width: 1023.99px) { .entryContainer, .entryContainer.editing { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto auto; } .entryTime { grid-column: 1 / span 2; border-right: 0; border-bottom: 1px solid var(--border); } .entryTimeEditing { padding: 14px 16px; } .entryTimeRow { gap: 10px; } .entryTimeRowLabel { min-width: 32px; }}.entryNote { padding: 15px; padding: 14px 18px; align-self: center; font-size: 0.92em; letter-spacing: 0.01em;}.entryNoteEmpty {
@@ -73,8 +195,11 @@.entryNote small { display: block; color: gray; margin-top: 5px; color: var(--green-bright); opacity: 0.7; margin-top: 6px; font-size: 0.72em; letter-spacing: 0.08em;}@media (max-width: 1023.99px) {
@@ -84,28 +209,55 @@}.entryButton { font-size: 1.3em; font-weight: bolder; color: #ffffff; font-size: 1em; cursor: pointer; border: 0; border-left: 1px solid var(--border); margin: 0; padding: 15px; padding: 0; height: 100%; background: transparent; color: var(--text-muted); display: flex; align-items: center; justify-content: center; transition: background 180ms ease, color 180ms ease;}.entryButton:hover { background: var(--green-dim); color: var(--green-bright);}.entryButton:focus-visible { outline: none; background: var(--green-dim); box-shadow: inset 0 0 0 1px var(--green);}@media (max-width: 1023.99px) { .entryButton { padding: 5px; padding: 10px; }}.entrySubmit,.entryEdit { background: var(--color-four);.entrySubmit { color: var(--green-bright);}.entrySubmit:hover { background: var(--green-dim); color: var(--green-hi);}.entryEdit:hover { color: var(--amber-bright); background: var(--amber-dim);}.entryRemove { background: var(--color-five);.entryRemove:hover { color: var(--terracotta-bright); background: var(--terracotta-dim);}
modified
styles/components/l10n.module.css
@@ -1,19 +1,33 @@.select { position: fixed; bottom: 5px; right: 5px; z-index: 2; background-color: var(--color-two); color: #ffffff; padding: 5px; border: none; background: var(--surface); color: var(--text-bright); padding: 8px 14px; border: 1px solid var(--border-green); border-radius: var(--radius); font-family: var(--font-mono); font-size: 0.85em; letter-spacing: 0.06em; cursor: pointer; transition: color 180ms ease, border-color 180ms ease, background 180ms ease; min-width: 180px;}@media (max-width: 1023.99px) { .select { top: 5px; right: 5px; text-align: center; bottom: auto; }.select:hover { color: var(--green-bright); border-color: var(--border-green-hover); background: var(--surface-2);}.select:focus-visible { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px var(--green-dim);}.select option { background: var(--surface); color: var(--text);}
modified
styles/components/sidebar.module.css
@@ -3,57 +3,136 @@ left: 0; top: 0; bottom: 0; width: 50px; background-color: #ffffff; width: 60px; background-color: var(--bg-deep); border-right: 1px solid var(--border); display: flex; justify-content: space-between; flex-direction: column; align-items: center; padding: 14px 0; z-index: 10;}@media (max-width: 1023.99px) { .side { width: 100%; height: 50px; height: 56px; bottom: 0; left: 0; top: auto; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 12px; border-right: 0; border-top: 1px solid var(--border); }}.title { color: var(--color-one);/* Brand mark + vertical wordmark */.brand { display: flex; flex-direction: column; align-items: center; gap: 16px; text-decoration: none; color: var(--text-muted); transition: color 200ms ease; padding-top: 4px;}.brand:hover { color: var(--green-bright);}.brandMark { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; color: var(--green-bright); filter: drop-shadow(0 0 6px rgba(107, 158, 120, 0.35)); transition: filter 220ms ease, transform 220ms ease;}.brandMark svg { display: block;}.brandMark svg circle:last-child { transform-origin: center; animation: brandPulse 2.2s ease-in-out infinite;}.brand:hover .brandMark { filter: drop-shadow(0 0 10px rgba(107, 158, 120, 0.6)); transform: scale(1.05);}@keyframes brandPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.55; transform: scale(0.82); }}.brandText { font-size: 1.3em; font-weight: 700; text-transform: uppercase; font-size: 1.7em; font-weight: 900; text-align: center; padding: 15px 0; white-space: nowrap; letter-spacing: 0.14em; color: var(--green-bright); writing-mode: vertical-rl; text-orientation: mixed; line-height: 1.7em; line-height: 1; white-space: nowrap; text-shadow: 0 0 10px var(--green-glow);}@media (max-width: 1023.99px) { .title { padding: 0 15px; font-size: 1.4em; .brand { flex-direction: row; gap: 10px; padding-top: 0; } .brandMark { width: 26px; height: 26px; } .brandMark svg { width: 18px; height: 18px; } .brandText { font-size: 0.8em; writing-mode: horizontal-tb; text-orientation: mixed; letter-spacing: 0.2em; }}.pages { display: flex; flex-direction: column; padding: 15px; gap: 4px; align-items: center;}@media (max-width: 1023.99px) { .pages { flex-direction: row; padding: 5px; gap: 2px; }}
@@ -61,59 +140,61 @@ text-decoration: none; position: relative; display: flex; margin-bottom: 1rem; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: var(--radius); color: var(--text-muted); transition: color 300ms, font-size 300ms, transform 300ms; font-size: 2em; font-weight: 100; color: rgba(0, 0, 0, 0.5); color 200ms ease, background 200ms ease;}.pageLink svg { width: 20px; height: 20px; fill: currentColor;}.pageLink:hover { transform: scale(1.5); color: var(--green-bright); background: var(--green-dim);}.pageLinkActive { color: rgba(0, 0, 0, 1); color: var(--green-bright); background: var(--green-dim); box-shadow: inset 2px 0 0 var(--green);}@media (max-width: 1023.99px) { .pageLink { font-size: 1.4em; margin-bottom: 0; margin-right: 15px; } .pageLink:hover { transform: none; font-size: 1.5em; .pageLinkActive { box-shadow: inset 0 2px 0 var(--green); }}.about { text-align: center; padding: 10px 0; display: block; display: flex; justify-content: center; align-items: center; width: 40px; height: 40px; border-radius: var(--radius); text-decoration: none; font-family: monospace; background: var(--color-two); color: #ffffff; line-height: 0; transition: transform 300ms; color: var(--text-muted); transition: color 200ms ease, background 200ms ease;}.about:hover { transform: scale(1.5);.about svg { width: 20px; height: 20px; fill: currentColor;}@media (max-width: 1023.99px) { .about { padding: 0 15px; display: flex; justify-content: center; align-items: center; height: 50px; }.about:hover { color: var(--amber); background: var(--amber-dim);}
modified
styles/components/timer.module.css
@@ -1,95 +1,149 @@.time { font-size: 10em; font-size: 8em; text-align: center; font-weight: lighter; font-weight: 300; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; color: var(--green-bright); text-shadow: 0 0 24px var(--green-glow); margin-bottom: 1rem; position: relative;}.time::before,.time::after { content: ""; display: block; width: 80px; height: 1px; margin: 0.5rem auto; background: linear-gradient(90deg, transparent, var(--green) 50%, transparent); opacity: 0.7;}@media (max-width: 1023.99px) { .time { font-size: 5em; font-size: 3.8em; } .time::before, .time::after { width: 50px; }}.inputs { text-align: center; margin-top: 1.5rem;}.note { width: 600px; width: 560px; max-width: calc(100vw - 80px); text-align: center; margin-top: 40px; border: 0; background: rgba(255, 255, 255, 0.7); color: #000000; padding: 15px 30px; font-size: 1.6em; transform: scale(1); border: 1px solid var(--border-green); background: var(--surface); color: var(--text); padding: 14px 20px; font-size: 0.95em; letter-spacing: 0.04em; border-radius: var(--radius); transition: transform 250ms, background 250ms; border-color 200ms ease, box-shadow 200ms ease, background 200ms ease;}.note:hover { border-color: var(--border-green-hover);}.note:hover,.note:focus { transform: scale(1.1); background: rgba(255, 255, 255, 1); z-index: 3; position: relative; outline: none; border-color: var(--green); background: var(--surface-2); box-shadow: 0 0 0 3px var(--green-dim);}.note::placeholder { font-size: 0.7em; font-size: 0.85em; text-transform: uppercase; font-weight: 100; font-weight: 500; letter-spacing: 0.15em; color: var(--text-dim);}@media (max-width: 1023.99px) { .note { padding: 10px 20px; font-size: 1.2em; width: 280px; margin-top: 20px; padding: 12px 16px; font-size: 0.9em; width: 300px; }}.buttons { text-align: center; display: flex; gap: 12px; justify-content: center; margin-top: 16px; width: 560px; max-width: calc(100vw - 80px); margin-left: auto; margin-right: auto;}.button { color: #ffffff; padding: 15px 30px; font-size: 1em; letter-spacing: 2px; width: 100%; flex: 1; padding: 12px 20px; font-size: 0.78em; letter-spacing: 0.16em; cursor: pointer; text-transform: uppercase; font-weight: 700; border: none; transform: scale(1); transition: transform 250ms; font-weight: 600; background: transparent; border-radius: var(--radius); transition: background 180ms ease, color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;}.button:hover,.button:focus { transform: scale(1.1); z-index: 3; position: relative;.button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--green-dim);}@media (max-width: 1023.99px) { .buttons { width: 300px; } .button { font-size: 0.9em; padding: 15px 25px; padding: 11px 16px; font-size: 0.72em; letter-spacing: 0.12em; }}.resetButton { background: var(--color-one); color: var(--text-muted); border: 1px solid var(--border);}.resetButton:hover { color: var(--terracotta-bright); border-color: var(--terracotta); background: var(--terracotta-dim);}.addButton { background: var(--color-two); color: var(--green-bright); border: 1px solid var(--green); background: var(--green-dim);}.addButton:hover { color: var(--green-hi); background: rgba(107, 158, 120, 0.22); border-color: var(--green-bright); box-shadow: 0 0 12px var(--green-glow);}
modified
styles/globals.css
@@ -1,10 +1,45 @@:root { --color-one: #0d0221; --color-two: #261447; --color-three: #d40078; --color-four: #ff3864; --color-five: #ff6c11; /* Warm-dark biohacker palette */ --bg: #0e0d0a; --bg-deep: #090806; --surface: #13120e; --surface-2: #18160f; --border: rgba(221, 215, 205, 0.06); --border-green: rgba(107, 158, 120, 0.2); --border-green-hover: rgba(107, 158, 120, 0.35); --text: #ddd7cd; --text-bright: #ede8e0; --text-muted: #a09890; --text-dim: #665f56; --green: #6b9e78; --green-bright: #7db88c; --green-hi: #95cca2; --green-dim: rgba(107, 158, 120, 0.12); --green-glow: rgba(107, 158, 120, 0.3); --amber: #c9a84c; --amber-bright: #ddc06a; --amber-dim: rgba(201, 168, 76, 0.1); --terracotta: #c47055; --terracotta-bright: #e38871; --terracotta-dim: rgba(196, 112, 85, 0.1); /* Legacy tokens kept so any missed reference still resolves to the new palette */ --color-one: var(--bg); --color-two: var(--surface); --color-three: var(--green); --color-four: var(--green-bright); --color-five: var(--amber); --radius: 3px; --radius-lg: 6px; --breakpoint-desktop-max: 1023.99px; --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", "SF Mono", Menlo, Consolas, ui-monospace, monospace;}* {
@@ -18,43 +53,71 @@ body {}body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; color: #ffffff; background-color: var(--color-one); font-family: var(--font-mono); color: var(--text); background-color: var(--bg); min-height: 100vh; width: 100%; overflow-x: hidden; text-shadow: rgba(0, 0, 0, 0.01) 0 0 1px; font-size: 15px; line-height: 1.6; letter-spacing: 0.01em; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; /* Subtle grid background for that operator-console feel */ background-image: radial-gradient( circle at 15% 10%, rgba(107, 158, 120, 0.04) 0%, transparent 45% ), radial-gradient( circle at 85% 85%, rgba(201, 168, 76, 0.025) 0%, transparent 50% ), linear-gradient(rgba(107, 158, 120, 0.015) 1px, transparent 1px), linear-gradient(90deg, rgba(107, 158, 120, 0.015) 1px, transparent 1px); background-size: auto, auto, 40px 40px, 40px 40px; background-position: 0 0, 0 0, -1px -1px, -1px -1px; background-attachment: fixed;}input,button,select,textarea { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-family: var(--font-mono); color: inherit;}a { color: inherit; color: var(--green-bright); text-decoration: none; transition: color 200ms ease;}a:hover { color: var(--green-hi);}:focus { outline: none;}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { outline: 1px solid var(--green); outline-offset: 2px; box-shadow: 0 0 0 3px var(--green-dim);}::selection { background: var(--green-dim); color: var(--green-hi);}.page-transition {
@@ -96,7 +159,7 @@ a {.fade-appear,.fade-enter { opacity: 0; transform: translateX(-100px); transform: translateX(-40px);}.fade-appear-active,
@@ -114,8 +177,164 @@ a {.fade-exit-active { opacity: 0; transform: translateX(100px); transform: translateX(40px); transition-duration: 250ms; transition-property: opacity, transform; transition-delay: 0ms !important;}/* Toastify overrides so it matches the dark warm palette */.Toastify__toast { font-family: var(--font-mono) !important; font-size: 0.85rem !important; border-radius: var(--radius) !important; background: var(--surface) !important; color: var(--text) !important; border: 1px solid var(--border-green); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);}.Toastify__toast--success { border-left: 3px solid var(--green);}.Toastify__toast--error { border-left: 3px solid var(--terracotta);}.Toastify__progress-bar--success { background: var(--green) !important;}.Toastify__progress-bar--error { background: var(--terracotta) !important;}/* ----- Shared page primitives (used by log, summary, about) ----- *//* Outer page grid: fixed sidebar + fluid main + right gutter. All content pages use this for consistent horizontal alignment. */.page-grid { display: grid; grid-template-columns: 60px minmax(0, 1fr) 60px; width: 100%; min-height: 100vh;}@media (max-width: 1023.99px) { .page-grid { grid-template-columns: 0 minmax(0, 1fr) 0; }}.page-main { grid-column: 2; width: 100%; max-width: 1100px; justify-self: start; padding: 52px 40px 120px; box-sizing: border-box;}@media (max-width: 1023.99px) { .page-main { padding: 32px 20px 120px; }}/* Page title bar: uppercase green with glow + hairline underline. Use on every content page's top to signal location. */.page-title { font-weight: 700; text-transform: uppercase; font-size: 1.8em; letter-spacing: 0.22em; color: var(--green-bright); text-shadow: 0 0 16px var(--green-glow); margin: 0 0 1.5rem; padding-bottom: 0.8rem; border-bottom: 1px solid var(--border);}@media (max-width: 1023.99px) { .page-title { font-size: 1.3em; }}/* Section label: small uppercase tracked label used above chart / section */.section-label { font-size: 0.72em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.22em; color: var(--text-muted); margin: 2rem 0 0.75rem;}.section-label:first-child { margin-top: 0;}/* Empty state: consistent centered message used when a page has no data. Positioned to fill the viewport so it renders identically across pages. */.empty-state { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; padding: 20px; pointer-events: none;}.empty-state > * { pointer-events: auto;}.empty-state-title { font-weight: 500; font-size: 1.15em; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-muted); margin: 0; padding-bottom: 14px; position: relative;}.empty-state-title::after { content: ""; position: absolute; width: 80px; left: 50%; transform: translateX(-50%); bottom: 0; height: 1px; background: linear-gradient( 90deg, transparent, var(--green) 50%, transparent );}.empty-state-hint { font-size: 0.78em; color: var(--text-dim); letter-spacing: 0.06em; max-width: 320px; line-height: 1.6;}@media (max-width: 1023.99px) { .empty-state-title { font-size: 0.9em; }}
modified
styles/pages/about.module.css
@@ -1,7 +1,7 @@@keyframes fadeRight { from { opacity: 0; transform: translateX(-100px); transform: translateX(-40px); } to { opacity: 1;
@@ -9,203 +9,197 @@ }}@keyframes scaleRight { from { transform: scaleX(0); } to { transform: scaleX(1); }}@keyframes scaleLeft { from { transform: scaleX(1); } to { transform: scaleX(0); }}.githubLink { position: absolute; top: 24px; right: 24px; text-decoration: none; z-index: 5;}.githubLogo { position: absolute; top: 20px; right: 20px; width: 50px; height: 50px; opacity: 0.5; transition-property: opacity; transition-duration: 250ms; width: 32px; height: 32px; color: var(--text-muted); opacity: 0.6; transition: opacity 200ms ease, color 200ms ease, transform 200ms ease;}.githubLogo:hover {.githubLink:hover .githubLogo { opacity: 1;}@media (max-width: 1023.99px) { .githubLogo { font-size: 3em; right: unset; left: 20px; }}.grid { display: grid; grid-template-columns: 20% 1fr 20%; grid-template-rows: repeat(3, 1fr); width: 100%; height: 100vh;}@media (max-width: 1023.99px) { .grid { grid-template-columns: 5% 1fr 5%; }}.main { grid-area: 2 / 2; display: flex; justify-content: space-between; flex-direction: column;}.heading { font-size: 4em; font-weight: lighter; margin-top: 0; opacity: 0; animation: fadeRight 1000ms ease-out forwards;}@media (max-width: 1023.99px) { .heading { font-size: 3em; } color: var(--green-bright); transform: rotate(6deg);}.blockquote { font-size: 1.5em; margin: 0 auto 40px auto; font-size: 1.35em; margin: 4rem auto 3.5rem auto; padding: 0 2rem; position: relative; max-width: 500px; max-width: 520px; color: var(--text); line-height: 1.6; letter-spacing: 0.01em; opacity: 0; animation: fadeRight 1000ms ease-out 500ms forwards; animation: fadeRight 600ms ease-out forwards;}.blockquote::before { content: "”"; position: absolute; top: 0; top: -1.5rem; right: 0; font-size: 5em; opacity: 0.1; font-size: 6em; opacity: 0.12; line-height: 0.5em; color: var(--green-bright); pointer-events: none;}@media (max-width: 1023.99px) { .blockquote { font-size: 1.2em; font-size: 1.05em; margin: 2.5rem auto 2.5rem auto; padding: 0 1rem; } .blockquote::before { font-size: 4em; }}.creator { font-size: 1.5em; display: block; margin-left: auto; opacity: 0.7; color: #ffffff; margin-right: 2rem; margin-bottom: 4rem; width: fit-content; font-size: 1.25em; color: var(--text-bright); text-decoration: none; transition: opacity 250ms; transition: color 200ms ease; position: relative; opacity: 0; animation: fadeRight 1000ms ease-out 1000ms forwards; animation: fadeRight 600ms ease-out 300ms forwards; padding-bottom: 6px;}.creator::before {.creator::after { content: ""; position: absolute; left: 0; right: 0; bottom: -10px; bottom: 0; height: 2px; background: var(--color-three); transform-origin: left; animation: scaleLeft 300ms forwards; background: var(--green); opacity: 0.25; pointer-events: none;}.creator::after {.creator::before { content: ""; position: absolute; left: 0; right: 0; bottom: -10px; bottom: 0; height: 2px; background: var(--color-three); opacity: 0.2; background: var(--green); transform-origin: left; transform: scaleX(1); animation: scaleLeftOut 400ms ease forwards; pointer-events: none;}.creator:hover { color: var(--green-bright);}.creator:hover::before { animation: scaleRight 300ms forwards; animation: scaleRightIn 300ms ease forwards;}@keyframes scaleLeftOut { from { transform: scaleX(1); transform-origin: right; } to { transform: scaleX(0); transform-origin: right; }}@keyframes scaleRightIn { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); transform-origin: left; }}@media (max-width: 1023.99px) { .creator { font-size: 1.2em; font-size: 1.05em; }}.descriptionContainer { display: flex; margin-top: 2em; display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; margin-top: 1rem;}@media (max-width: 1023.99px) { .descriptionContainer { display: none; grid-template-columns: 1fr; gap: 1.5rem; }}.keysColumn { flex: 1;}.sectionTitle { font-size: 1.5em; position: relative; text-align: left; max-width: 500px; opacity: 0; margin-bottom: 0; animation: fadeRight 1000ms ease-out 1500ms forwards; display: flex; flex-direction: column; gap: 6px;}.keysDescription { font-size: 1em; position: relative; font-size: 0.88em; color: var(--text); display: flex; align-items: center; gap: 12px; margin: 0; padding: 4px 0; opacity: 0; animation: fadeRight 1000ms ease-out 2000ms forwards; margin-bottom: 0; animation: fadeRight 600ms ease-out 400ms forwards;}.keysDescription span { width: 45px; display: inline-block; background: rgba(255, 255, 255, 0.3); font-family: monospace; min-width: 58px; text-align: center; font-size: 1.1em; padding: 3px 8px; background: var(--surface); border: 1px solid var(--border-green); border-radius: 2px; font-family: var(--font-mono); font-size: 0.85em; color: var(--green-bright); letter-spacing: 0.04em;}@media (max-width: 1023.99px) { .keysDescription { font-size: 1.2em; font-size: 0.82em; }}.languageRow { margin-top: 0.5rem; margin-bottom: 2rem;}
modified
styles/pages/index.module.css
@@ -1,7 +1,7 @@@keyframes fadeUp { from { opacity: 0; transform: translateY(100px); transform: translateY(40px); } to { opacity: 1;
@@ -10,39 +10,52 @@}.background { position: absolute; position: fixed; z-index: -1; top: 0; right: 0; bottom: 0; left: 0; background-image: linear-gradient(#0d0221, transparent), linear-gradient(to top left, #580215, transparent), linear-gradient(to top right, #210d00, transparent); background-blend-mode: screen; background-size: cover; background: radial-gradient( circle at 20% 15%, rgba(107, 158, 120, 0.08) 0%, transparent 55% ), radial-gradient( circle at 80% 85%, rgba(201, 168, 76, 0.05) 0%, transparent 60% ), radial-gradient( circle at 50% 50%, rgba(196, 112, 85, 0.04) 0%, transparent 70% ); pointer-events: none;}.grid { display: grid; grid-template-columns: 20% 1fr 20%; grid-template-rows: repeat(3, 1fr); grid-template-columns: 60px 1fr 80px; grid-template-rows: 1fr; width: 100%; height: 100vh; min-height: 100vh;}@media (max-width: 1023.99px) { .grid { grid-template-columns: 5% 1fr 5%; grid-template-columns: 0 1fr 0; }}.main { grid-area: 2 / 2; grid-area: 1 / 2; justify-content: center; align-items: center; display: flex; flex-direction: column; animation: fadeUp 500ms forwards; min-height: 100vh; padding: 48px 24px; animation: fadeUp 500ms ease-out forwards;}
modified
styles/pages/log.module.css
@@ -1,179 +1,171 @@.grid {.tiles { display: grid; grid-template-columns: 250px 1fr; width: 100%; min-height: 100vh; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 28px;}@media (max-width: 1023.99px) { .grid { grid-template-columns: 1fr; grid-auto-rows: min-content; }}.details { position: fixed;.tile { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; display: flex; flex-direction: column; justify-content: center; grid-column: 1; width: 250px; height: 100vh; padding: 15px; box-sizing: border-box; background-color: #e2e2e2; color: var(--color-one); gap: 6px; transition: border-color 180ms ease;}@media (max-width: 1023.99px) { .details { position: relative; grid-column: 1; height: auto; margin-bottom: 50px; width: 100%; }.tile:hover { border-color: var(--border-green);}.tileLabel { font-size: 0.65em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.22em; color: var(--text-muted);}.main { grid-column: 2; min-height: 100vh; padding: 50px; box-sizing: border-box;.tileValue { font-size: 1.5em; font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-bright); letter-spacing: 0.01em;}.mainEmpty { grid-column: 1 / span 2;.tileExport { position: relative; background: transparent; border: 1px solid var(--border-green); border-radius: var(--radius); padding: 14px 16px; display: flex; align-items: center; justify-content: center; 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;}@media (max-width: 1023.99px) { .main { grid-column: 1; padding-top: 0; min-height: auto; padding-bottom: 100px; }.tileExport:hover { background: var(--green-dim); border-color: var(--green); color: var(--green-hi);} .mainEmpty { grid-column: 1; height: 100vh; }.tileExportGlyph { font-size: 1.5em; font-weight: 700; line-height: 1; letter-spacing: 0;}.topBar { display: flex; justify-content: space-between; margin-bottom: 40px; align-items: center; gap: 16px; margin-bottom: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--border);}@media (max-width: 1023.99px) { .topBar { flex-direction: column; align-items: stretch; }}.filters { display: block; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;}.filters span { font-size: 0.8em;.filtersLabel { font-size: 0.68em; text-transform: uppercase; font-weight: lighter; display: block; font-weight: 600; letter-spacing: 0.22em; color: var(--text-muted); margin-right: 4px;}.filterButton { background: var(--color-three); border-bottom: 1px solid var(--color-four); padding: 6px 9px; color: #ffffff; border: none; margin-right: 10px; margin-top: 10px; background: rgba(221, 215, 205, 0.04); color: var(--text-muted); border: 1px solid rgba(221, 215, 205, 0.1); padding: 6px 12px; font-family: var(--font-mono); font-size: 0.78em; letter-spacing: 0.02em; border-radius: 2px; cursor: pointer; transition: background 180ms ease, color 180ms ease, border-color 180ms ease;}.reset { font-size: 1.1em; border: 0; background-color: var(--color-five); border-bottom: 1px solid var(--color-four); padding: 10px 25px; letter-spacing: 1px; text-transform: uppercase; color: #ffffff; font-weight: bolder; cursor: pointer;.filterButton:hover { background: var(--green-dim); color: var(--green-bright); border-color: var(--border-green);}@media (max-width: 1023.99px) { .reset { font-size: 0.8em; padding: 8px 15px; margin-top: 15px; }}.csvButton { text-align: center; padding: 5px 0; display: block; text-decoration: none; font-family: monospace; background: var(--color-one); color: #ffffff; margin: 5px;}@media (max-width: 1023.99px) { .csvButton { padding: 10px 0; }.filterButtonActive { background: var(--green-dim); color: var(--green-bright); border-color: var(--border-green);}.nothing { font-weight: 100; font-size: 2.5em; text-align: center; position: relative;.reset { font-size: 0.72em; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 600; color: var(--terracotta-bright); background: var(--terracotta-dim); border: 1px solid rgba(196, 112, 85, 0.22); padding: 6px 14px; border-radius: 2px; cursor: pointer; transition: background 180ms ease, color 180ms ease, border-color 180ms ease;}.nothing::after { content: ""; position: absolute; width: 100%; left: 0; bottom: -15px; height: 3px; background-color: var(--color-three);.reset:hover { background: rgba(196, 112, 85, 0.2); color: #f09a85; border-color: var(--terracotta);}@media (max-width: 1023.99px) { .nothing { font-size: 1.4em; .reset { align-self: flex-start; }}.total { font-weight: bolder; font-size: 2em; padding: 5px; margin-bottom: 20px;}.total:last-child { margin-bottom: 0;}.total span { font-size: 0.4em;.nothingFiltered { padding: 48px 16px; text-align: center; color: var(--text-muted); font-size: 0.85em; letter-spacing: 0.1em; text-transform: uppercase; font-weight: lighter; display: block; border: 1px dashed var(--border); border-radius: var(--radius); background: rgba(221, 215, 205, 0.02);}
modified
styles/pages/summary.module.css
@@ -1,66 +1,51 @@.grid {.tiles { display: grid; grid-template-columns: 20% 1fr 20%; width: 100%; height: 100vh; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 32px;}@media (max-width: 1023.99px) { .grid { grid-template-columns: 5% 1fr 5%; }}.main { grid-area: 1 / 2;}.mainEmpty { grid-area: 1 / 2;.tile { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 6px; transition: border-color 180ms ease;}.title { font-weight: 300; text-transform: uppercase; font-size: 6em; margin-bottom: 2rem;}.canvasWrapper { background-color: #ffffff; padding: 0.25rem; margin-bottom: 2rem;.tile:hover { border-color: var(--border-green);}.totalTime { font-size: 4em; font-weight: 900; margin-bottom: 3rem;.tileLabel { font-size: 0.65em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.22em; color: var(--text-muted);}.nothing { font-weight: 100; font-size: 2.5em; text-align: center; position: relative;.tileValue { font-size: 1.6em; font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-bright); letter-spacing: 0.01em;}.nothing::after { content: ""; position: absolute; width: 100%; left: 0; bottom: -15px; height: 3px; background-color: var(--color-three);.chartCard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; transition: border-color 180ms ease;}@media (max-width: 1023.99px) { .nothing { font-size: 1.4em; }.chartCard:hover { border-color: var(--border-green);}