initialer Commit
This commit is contained in:
commit
35659829c9
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
33
README.md
Normal file
33
README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Logseq Page Timetracker
|
||||
|
||||
Track time per page in Logseq with one active timer at a time.
|
||||
|
||||
## Current status
|
||||
|
||||
Initial MVP implementation in progress.
|
||||
|
||||
## Development
|
||||
|
||||
1. Install dependencies: `npm install`
|
||||
2. Build once: `npm run build`
|
||||
3. Build in watch mode: `npm run dev`
|
||||
4. In Logseq, load plugin from this folder.
|
||||
|
||||
## Commands (MVP)
|
||||
|
||||
- Start tracking current page
|
||||
- Stop active tracking
|
||||
- Switch tracking to current page
|
||||
- Open timetracker UI
|
||||
- Export entries JSON
|
||||
|
||||
## Data model
|
||||
|
||||
- Single active timer at any time.
|
||||
- Completed entries with editable timestamps.
|
||||
- Stored in browser local storage by plugin key.
|
||||
|
||||
## Export
|
||||
|
||||
- JSON export is the canonical, versioned format.
|
||||
- The file can be consumed by external scripts.
|
||||
28
index.html
Normal file
28
index.html
Normal file
@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Logseq Page Timetracker</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">Loading timetracker...</div>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1163
package-lock.json
generated
Normal file
1163
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "logseq-page-timetracker",
|
||||
"version": "0.1.0",
|
||||
"description": "Logseq plugin to track time per page with a single active timer.",
|
||||
"main": "dist/index.html",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"logseq",
|
||||
"plugin",
|
||||
"time-tracking"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@logseq/libs": "^0.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.1"
|
||||
},
|
||||
"logseq": {
|
||||
"id": "logseq-page-timetracker",
|
||||
"title": "Page Timetracker",
|
||||
"icon": "./icon.png"
|
||||
}
|
||||
}
|
||||
72
src/aggregation.ts
Normal file
72
src/aggregation.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { DaySummary, TimeEntry, WeekSummary } from "./types";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function dayStartMs(value: number): number {
|
||||
const d = new Date(value);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
function dayKey(value: number): string {
|
||||
const d = new Date(value);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function isoWeekKey(value: number): string {
|
||||
const d = new Date(value);
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((date.getTime() - yearStart.getTime()) / DAY_MS) + 1) / 7);
|
||||
return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function splitEntryAcrossDays(entry: TimeEntry): DaySummary[] {
|
||||
const result: DaySummary[] = [];
|
||||
let cursor = entry.startedAtEpochMs;
|
||||
const end = entry.endedAtEpochMs;
|
||||
|
||||
while (cursor < end) {
|
||||
const startOfDay = dayStartMs(cursor);
|
||||
const nextDay = startOfDay + DAY_MS;
|
||||
const segmentEnd = Math.min(end, nextDay);
|
||||
result.push({
|
||||
day: dayKey(cursor),
|
||||
durationMs: segmentEnd - cursor
|
||||
});
|
||||
cursor = segmentEnd;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function computeDaySummaries(entries: TimeEntry[]): DaySummary[] {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
for (const segment of splitEntryAcrossDays(entry)) {
|
||||
map.set(segment.day, (map.get(segment.day) ?? 0) + segment.durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([day, durationMs]) => ({ day, durationMs }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
export function computeWeekSummaries(entries: TimeEntry[]): WeekSummary[] {
|
||||
const daySummaries = computeDaySummaries(entries);
|
||||
const map = new Map<string, number>();
|
||||
|
||||
for (const day of daySummaries) {
|
||||
const week = isoWeekKey(new Date(`${day.day}T12:00:00`).getTime());
|
||||
map.set(week, (map.get(week) ?? 0) + day.durationMs);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([week, durationMs]) => ({ week, durationMs }))
|
||||
.sort((a, b) => a.week.localeCompare(b.week));
|
||||
}
|
||||
27
src/export.ts
Normal file
27
src/export.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { TimeEntry, TrackerState } from "./types";
|
||||
|
||||
interface ExportEnvelope {
|
||||
schema: "logseq-page-timetracker-export";
|
||||
schemaVersion: 1;
|
||||
exportedAtIso: string;
|
||||
entries: TimeEntry[];
|
||||
}
|
||||
|
||||
export function createExportPayload(state: TrackerState): ExportEnvelope {
|
||||
return {
|
||||
schema: "logseq-page-timetracker-export",
|
||||
schemaVersion: 1,
|
||||
exportedAtIso: new Date().toISOString(),
|
||||
entries: state.entries
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, content: string): void {
|
||||
const blob = new Blob([content], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
202
src/index.ts
Normal file
202
src/index.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import "@logseq/libs";
|
||||
|
||||
import { createExportPayload, downloadJson } from "./export";
|
||||
import { TimerService } from "./timer-service";
|
||||
import type { PageRef } from "./types";
|
||||
import { TimetrackerUi } from "./ui";
|
||||
|
||||
declare const logseq: any;
|
||||
|
||||
const service = new TimerService();
|
||||
let isUiVisible = false;
|
||||
|
||||
async function currentPage(): Promise<PageRef | null> {
|
||||
const page = await logseq.Editor.getCurrentPage();
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
uuid: page.uuid ?? null,
|
||||
name: page.name ?? page.originalName ?? "Untitled"
|
||||
};
|
||||
}
|
||||
|
||||
async function listPages(): Promise<PageRef[]> {
|
||||
try {
|
||||
const rows = await logseq.DB.datascriptQuery(
|
||||
`[:find ?name ?uuid
|
||||
:where
|
||||
[?p :block/name ?name]
|
||||
[?p :block/uuid ?uuid]]`
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row: unknown) => {
|
||||
if (!Array.isArray(row) || row.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [name, uuid] = row;
|
||||
return {
|
||||
name: String(name),
|
||||
uuid: uuid ? String(uuid) : null
|
||||
};
|
||||
})
|
||||
.filter((page: PageRef | null): page is PageRef => Boolean(page))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function openUi(): void {
|
||||
logseq.showMainUI({ autoFocus: false });
|
||||
}
|
||||
|
||||
function closeUi(): void {
|
||||
logseq.hideMainUI();
|
||||
}
|
||||
|
||||
function toggleUi(): void {
|
||||
if (isUiVisible) {
|
||||
closeUi();
|
||||
return;
|
||||
}
|
||||
|
||||
openUi();
|
||||
}
|
||||
|
||||
async function startCurrentPage(): Promise<void> {
|
||||
const page = await currentPage();
|
||||
if (!page) {
|
||||
logseq.App.showMsg("No current page found.", "warning");
|
||||
return;
|
||||
}
|
||||
service.startForPage(page);
|
||||
logseq.App.showMsg(`Tracking started for ${page.name}.`, "success");
|
||||
}
|
||||
|
||||
async function switchCurrentPage(): Promise<void> {
|
||||
const page = await currentPage();
|
||||
if (!page) {
|
||||
logseq.App.showMsg("No current page found.", "warning");
|
||||
return;
|
||||
}
|
||||
service.switchToPage(page);
|
||||
logseq.App.showMsg(`Tracking switched to ${page.name}.`, "success");
|
||||
}
|
||||
|
||||
function stopActive(): void {
|
||||
service.stopActive();
|
||||
logseq.App.showMsg("Active tracking stopped.", "success");
|
||||
}
|
||||
|
||||
function exportJson(): void {
|
||||
const payload = createExportPayload(service.getState());
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
downloadJson(`timetracker-export-${new Date().toISOString().slice(0, 10)}.json`, content);
|
||||
logseq.App.showMsg("JSON export downloaded.", "success");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ui = new TimetrackerUi(service, currentPage, listPages);
|
||||
|
||||
logseq.setMainUIInlineStyle({
|
||||
zIndex: 11,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
left: "0",
|
||||
top: "0",
|
||||
backgroundColor: "transparent",
|
||||
overflow: "hidden"
|
||||
});
|
||||
|
||||
ui.init();
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-start-current-page",
|
||||
label: "Page Timetracker: Start tracking current page"
|
||||
},
|
||||
startCurrentPage
|
||||
);
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-stop-active",
|
||||
label: "Page Timetracker: Stop active tracking"
|
||||
},
|
||||
stopActive
|
||||
);
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-switch-current-page",
|
||||
label: "Page Timetracker: Switch tracking to current page"
|
||||
},
|
||||
switchCurrentPage
|
||||
);
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-open-ui",
|
||||
label: "Page Timetracker: Open timetracker UI"
|
||||
},
|
||||
openUi
|
||||
);
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-close-ui",
|
||||
label: "Page Timetracker: Close timetracker UI"
|
||||
},
|
||||
closeUi
|
||||
);
|
||||
|
||||
logseq.App.registerCommandPalette(
|
||||
{
|
||||
key: "page-timetracker-export-json",
|
||||
label: "Page Timetracker: Export entries JSON"
|
||||
},
|
||||
exportJson
|
||||
);
|
||||
|
||||
logseq.Editor.registerSlashCommand("Timetracker: Start current page", startCurrentPage);
|
||||
logseq.Editor.registerSlashCommand("Timetracker: Stop active", stopActive);
|
||||
logseq.Editor.registerSlashCommand("Timetracker: Switch to current page", switchCurrentPage);
|
||||
|
||||
logseq.App.registerPageMenuItem("Track this page", startCurrentPage);
|
||||
|
||||
logseq.App.registerUIItem("toolbar", {
|
||||
key: "page-timetracker-toolbar",
|
||||
template: `<a class="button" data-on-click="pageTimetrackerToggle" title="Toggle Timetracker">TT</a>`
|
||||
});
|
||||
|
||||
logseq.provideModel({
|
||||
pageTimetrackerOpen: openUi,
|
||||
pageTimetrackerClose: closeUi,
|
||||
pageTimetrackerToggle: toggleUi
|
||||
});
|
||||
|
||||
logseq.on("ui:visible:changed", ({ visible }: { visible: boolean }) => {
|
||||
isUiVisible = visible;
|
||||
if (visible) {
|
||||
ui.render();
|
||||
}
|
||||
});
|
||||
|
||||
logseq.beforeunload(() => {
|
||||
service.persist();
|
||||
ui.destroy();
|
||||
});
|
||||
|
||||
logseq.App.showMsg("Page Timetracker loaded.", "success");
|
||||
}
|
||||
|
||||
logseq.ready(main).catch((error: unknown) => {
|
||||
console.error("Page Timetracker failed to load", error);
|
||||
});
|
||||
47
src/storage.ts
Normal file
47
src/storage.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { TrackerState } from "./types";
|
||||
|
||||
const STORAGE_KEY = "logseq-page-timetracker/state";
|
||||
const SCHEMA = "logseq-page-timetracker";
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
function defaultState(): TrackerState {
|
||||
return {
|
||||
schema: SCHEMA,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
activeTimer: null,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
|
||||
export function loadState(): TrackerState {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return defaultState();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<TrackerState>;
|
||||
if (parsed.schema !== SCHEMA) {
|
||||
return defaultState();
|
||||
}
|
||||
|
||||
return {
|
||||
schema: SCHEMA,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
activeTimer: parsed.activeTimer ?? null,
|
||||
entries: Array.isArray(parsed.entries) ? parsed.entries : []
|
||||
};
|
||||
} catch {
|
||||
return defaultState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: TrackerState): void {
|
||||
const persistable: TrackerState = {
|
||||
schema: SCHEMA,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
activeTimer: state.activeTimer,
|
||||
entries: state.entries
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistable));
|
||||
}
|
||||
140
src/timer-service.ts
Normal file
140
src/timer-service.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { loadState, saveState } from "./storage";
|
||||
import type { PageRef, TimeEntry, TrackerState } from "./types";
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function id(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
function assertValidRange(startedAtEpochMs: number, endedAtEpochMs: number): void {
|
||||
if (!Number.isFinite(startedAtEpochMs) || !Number.isFinite(endedAtEpochMs)) {
|
||||
throw new Error("Invalid timestamp.");
|
||||
}
|
||||
if (endedAtEpochMs < startedAtEpochMs) {
|
||||
throw new Error("End must be greater than or equal to start.");
|
||||
}
|
||||
}
|
||||
|
||||
export class TimerService {
|
||||
private state: TrackerState;
|
||||
|
||||
constructor() {
|
||||
this.state = loadState();
|
||||
}
|
||||
|
||||
public getState(): TrackerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public startForPage(page: PageRef): TrackerState {
|
||||
if (this.state.activeTimer?.pageUuid === page.uuid) {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
if (this.state.activeTimer) {
|
||||
this.stopActive();
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
activeTimer: {
|
||||
id: id(),
|
||||
pageUuid: page.uuid,
|
||||
pageNameSnapshot: page.name,
|
||||
startedAtEpochMs: nowMs()
|
||||
}
|
||||
};
|
||||
this.persist();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public stopActive(): TrackerState {
|
||||
const active = this.state.activeTimer;
|
||||
if (!active) {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
const endedAtEpochMs = nowMs();
|
||||
assertValidRange(active.startedAtEpochMs, endedAtEpochMs);
|
||||
|
||||
const entry: TimeEntry = {
|
||||
id: id(),
|
||||
pageUuid: active.pageUuid,
|
||||
pageNameSnapshot: active.pageNameSnapshot,
|
||||
startedAtEpochMs: active.startedAtEpochMs,
|
||||
endedAtEpochMs,
|
||||
note: "",
|
||||
createdAtEpochMs: endedAtEpochMs,
|
||||
updatedAtEpochMs: endedAtEpochMs,
|
||||
revision: 1
|
||||
};
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
activeTimer: null,
|
||||
entries: [entry, ...this.state.entries]
|
||||
};
|
||||
|
||||
this.persist();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public switchToPage(page: PageRef): TrackerState {
|
||||
if (this.state.activeTimer?.pageUuid === page.uuid) {
|
||||
return this.state;
|
||||
}
|
||||
this.stopActive();
|
||||
return this.startForPage(page);
|
||||
}
|
||||
|
||||
public editEntry(
|
||||
entryId: string,
|
||||
update: {
|
||||
pageNameSnapshot: string;
|
||||
pageUuid: string | null;
|
||||
startedAtEpochMs: number;
|
||||
endedAtEpochMs: number;
|
||||
note: string;
|
||||
}
|
||||
): TrackerState {
|
||||
assertValidRange(update.startedAtEpochMs, update.endedAtEpochMs);
|
||||
const ts = nowMs();
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
entries: this.state.entries.map((entry) => {
|
||||
if (entry.id !== entryId) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
...update,
|
||||
updatedAtEpochMs: ts,
|
||||
revision: entry.revision + 1
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
this.persist();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public deleteEntry(entryId: string): TrackerState {
|
||||
this.state = {
|
||||
...this.state,
|
||||
entries: this.state.entries.filter((entry) => entry.id !== entryId)
|
||||
};
|
||||
this.persist();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public persist(): void {
|
||||
saveState(this.state);
|
||||
}
|
||||
}
|
||||
42
src/types.ts
Normal file
42
src/types.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export type IsoDateTime = string;
|
||||
|
||||
export interface ActiveTimer {
|
||||
id: string;
|
||||
pageUuid: string | null;
|
||||
pageNameSnapshot: string;
|
||||
startedAtEpochMs: number;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
pageUuid: string | null;
|
||||
pageNameSnapshot: string;
|
||||
startedAtEpochMs: number;
|
||||
endedAtEpochMs: number;
|
||||
note: string;
|
||||
createdAtEpochMs: number;
|
||||
updatedAtEpochMs: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface TrackerState {
|
||||
schema: "logseq-page-timetracker";
|
||||
schemaVersion: number;
|
||||
activeTimer: ActiveTimer | null;
|
||||
entries: TimeEntry[];
|
||||
}
|
||||
|
||||
export interface PageRef {
|
||||
uuid: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DaySummary {
|
||||
day: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface WeekSummary {
|
||||
week: string;
|
||||
durationMs: number;
|
||||
}
|
||||
390
src/ui.ts
Normal file
390
src/ui.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import { computeDaySummaries, computeWeekSummaries } from "./aggregation";
|
||||
import { createExportPayload, downloadJson } from "./export";
|
||||
import { TimerService } from "./timer-service";
|
||||
import type { PageRef, TimeEntry } from "./types";
|
||||
|
||||
interface EditDraft {
|
||||
entryId: string;
|
||||
pageNameSnapshot: string;
|
||||
startedAtInput: string;
|
||||
endedAtInput: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toIsoLocalInput(epochMs: number): string {
|
||||
const d = new Date(epochMs);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${h}:${m}`;
|
||||
}
|
||||
|
||||
function parseLocalInput(value: string): number {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
function row(entry: TimeEntry): string {
|
||||
const duration = entry.endedAtEpochMs - entry.startedAtEpochMs;
|
||||
return `<tr>
|
||||
<td>${entry.pageNameSnapshot}</td>
|
||||
<td>${new Date(entry.startedAtEpochMs).toLocaleString()}</td>
|
||||
<td>${new Date(entry.endedAtEpochMs).toLocaleString()}</td>
|
||||
<td>${formatDuration(duration)}</td>
|
||||
<td>${entry.note || ""}</td>
|
||||
<td>
|
||||
<button class="tt-edit" data-id="${entry.id}">Edit</button>
|
||||
<button class="tt-delete" data-id="${entry.id}">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export class TimetrackerUi {
|
||||
private readonly service: TimerService;
|
||||
private readonly resolveCurrentPage: () => Promise<PageRef | null>;
|
||||
private readonly listPages: () => Promise<PageRef[]>;
|
||||
private intervalId: number | null = null;
|
||||
private editDraft: EditDraft | null = null;
|
||||
private pageOptions: PageRef[] = [];
|
||||
|
||||
constructor(
|
||||
service: TimerService,
|
||||
resolveCurrentPage: () => Promise<PageRef | null>,
|
||||
listPages: () => Promise<PageRef[]>
|
||||
) {
|
||||
this.service = service;
|
||||
this.resolveCurrentPage = resolveCurrentPage;
|
||||
this.listPages = listPages;
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
this.ensureContainer();
|
||||
this.bindEvents();
|
||||
this.render();
|
||||
this.intervalId = window.setInterval(() => {
|
||||
// Keep focus stable while an entry is being edited.
|
||||
if (this.editDraft) {
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.intervalId !== null) {
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
const app = this.ensureContainer();
|
||||
const state = this.service.getState();
|
||||
const active = state.activeTimer;
|
||||
const activeDuration = active ? Date.now() - active.startedAtEpochMs : 0;
|
||||
|
||||
const daySummaries = computeDaySummaries(state.entries);
|
||||
const weekSummaries = computeWeekSummaries(state.entries);
|
||||
|
||||
const today = new Date();
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const todayMs = daySummaries.find((x) => x.day === todayKey)?.durationMs ?? 0;
|
||||
const currentWeek = weekSummaries[weekSummaries.length - 1]?.durationMs ?? 0;
|
||||
|
||||
app.innerHTML = `
|
||||
<style>
|
||||
.tt-overlay { display: flex; align-items: flex-start; justify-content: center; width: 100%; height: 100%; padding: 6vh 16px 16px; box-sizing: border-box; background: rgba(15, 23, 42, 0.28); }
|
||||
.tt-panel { width: min(1100px, 96vw); max-height: 84vh; overflow: auto; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; box-shadow: 0 18px 48px rgba(15, 23, 42, 0.24); padding: 12px; }
|
||||
.tt-shell { display: grid; gap: 12px; }
|
||||
.tt-cards { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 8px; }
|
||||
.tt-card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; }
|
||||
.tt-card h4 { margin: 0; font-size: 12px; color: #6b7280; }
|
||||
.tt-card p { margin: 6px 0 0; font-size: 18px; }
|
||||
.tt-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tt-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.tt-edit-panel { display: grid; gap: 8px; background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; }
|
||||
.tt-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 8px; }
|
||||
.tt-edit-grid label { display: grid; gap: 4px; font-size: 12px; color: #475569; }
|
||||
.tt-edit-grid label.tt-span-2 { grid-column: span 2; }
|
||||
.tt-edit-grid input, .tt-edit-grid textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 6px; padding: 6px 8px; font: inherit; }
|
||||
.tt-edit-grid textarea { min-height: 72px; resize: vertical; }
|
||||
.tt-actions button { border: 1px solid #d1d5db; background: white; border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #e5e7eb; }
|
||||
th, td { border-bottom: 1px solid #f1f5f9; padding: 6px; text-align: left; font-size: 12px; }
|
||||
</style>
|
||||
<div class="tt-overlay" id="tt-overlay">
|
||||
<div class="tt-panel">
|
||||
<div class="tt-shell">
|
||||
<div class="tt-header">
|
||||
<strong>Page Timetracker</strong>
|
||||
<button id="tt-close">Close</button>
|
||||
</div>
|
||||
<div class="tt-cards">
|
||||
<div class="tt-card"><h4>Active</h4><p>${active ? active.pageNameSnapshot : "None"}</p></div>
|
||||
<div class="tt-card"><h4>Active Time</h4><p>${formatDuration(activeDuration)}</p></div>
|
||||
<div class="tt-card"><h4>Today</h4><p>${formatDuration(todayMs)}</p></div>
|
||||
<div class="tt-card"><h4>This Week</h4><p>${formatDuration(currentWeek)}</p></div>
|
||||
</div>
|
||||
<div class="tt-actions">
|
||||
<button id="tt-start">Start current page</button>
|
||||
<button id="tt-stop">Stop active</button>
|
||||
<button id="tt-export">Export JSON</button>
|
||||
</div>
|
||||
${this.editDraft ? this.renderEditPanel() : ""}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Duration</th>
|
||||
<th>Note</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.entries.map(row).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEditPanel(): string {
|
||||
if (!this.editDraft) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tt-edit-panel">
|
||||
<strong>Edit entry</strong>
|
||||
<div class="tt-edit-grid">
|
||||
<label>
|
||||
<span>Page</span>
|
||||
<input list="tt-pages" data-edit-field="pageNameSnapshot" value="${this.escapeAttribute(this.editDraft.pageNameSnapshot)}" />
|
||||
<datalist id="tt-pages">
|
||||
${this.pageOptions
|
||||
.map((page) => `<option value="${this.escapeAttribute(page.name)}"></option>`)
|
||||
.join("")}
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input type="datetime-local" data-edit-field="startedAtInput" value="${this.escapeAttribute(this.editDraft.startedAtInput)}" />
|
||||
</label>
|
||||
<label>
|
||||
<span>End</span>
|
||||
<input type="datetime-local" data-edit-field="endedAtInput" value="${this.escapeAttribute(this.editDraft.endedAtInput)}" />
|
||||
</label>
|
||||
<label class="tt-span-2">
|
||||
<span>Note</span>
|
||||
<textarea data-edit-field="note">${this.escapeHtml(this.editDraft.note)}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="tt-actions">
|
||||
<button id="tt-edit-save">Save</button>
|
||||
<button id="tt-edit-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private ensureContainer(): HTMLElement {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) {
|
||||
throw new Error("Missing UI container #app.");
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
document.addEventListener("click", async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-start") {
|
||||
const page = await this.resolveCurrentPage();
|
||||
if (!page) {
|
||||
logseq.App.showMsg("No current page found.", "warning");
|
||||
return;
|
||||
}
|
||||
this.service.startForPage(page);
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-stop") {
|
||||
this.service.stopActive();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-close") {
|
||||
logseq.hideMainUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-overlay") {
|
||||
logseq.hideMainUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-export") {
|
||||
const payload = createExportPayload(this.service.getState());
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
downloadJson(`timetracker-export-${new Date().toISOString().slice(0, 10)}.json`, content);
|
||||
logseq.App.showMsg("Exported JSON file.", "success");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-edit-save") {
|
||||
this.saveEditDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === "tt-edit-cancel") {
|
||||
this.editDraft = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const editId = target.getAttribute("data-id");
|
||||
if (target.classList.contains("tt-edit") && editId) {
|
||||
await this.startEditing(editId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains("tt-delete") && editId) {
|
||||
this.deleteEntry(editId);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("input", (event) => {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
if (!target || !this.editDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const field = target.getAttribute("data-edit-field");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "pageNameSnapshot" || field === "startedAtInput" || field === "endedAtInput" || field === "note") {
|
||||
this.editDraft = {
|
||||
...this.editDraft,
|
||||
[field]: target.value
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (this.editDraft) {
|
||||
this.editDraft = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
logseq.hideMainUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private deleteEntry(entryId: string): void {
|
||||
const ok = window.confirm("Delete this entry?");
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
this.service.deleteEntry(entryId);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async startEditing(entryId: string): Promise<void> {
|
||||
const entry = this.service.getState().entries.find((x) => x.id === entryId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.pageOptions = await this.listPages();
|
||||
} catch {
|
||||
this.pageOptions = [];
|
||||
}
|
||||
|
||||
this.editDraft = {
|
||||
entryId,
|
||||
pageNameSnapshot: entry.pageNameSnapshot,
|
||||
startedAtInput: toIsoLocalInput(entry.startedAtEpochMs),
|
||||
endedAtInput: toIsoLocalInput(entry.endedAtEpochMs),
|
||||
note: entry.note
|
||||
};
|
||||
this.render();
|
||||
}
|
||||
|
||||
private saveEditDraft(): void {
|
||||
if (!this.editDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.service.getState().entries.find((x) => x.id === this.editDraft?.entryId);
|
||||
if (!entry) {
|
||||
this.editDraft = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNameSnapshot = this.editDraft.pageNameSnapshot.trim();
|
||||
if (!pageNameSnapshot) {
|
||||
logseq.App.showMsg("Page name is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedPage = this.pageOptions.find((page) => page.name.toLowerCase() === pageNameSnapshot.toLowerCase());
|
||||
|
||||
const startedAtEpochMs = parseLocalInput(this.editDraft.startedAtInput);
|
||||
const endedAtEpochMs = parseLocalInput(this.editDraft.endedAtInput);
|
||||
|
||||
if (!Number.isFinite(startedAtEpochMs) || !Number.isFinite(endedAtEpochMs)) {
|
||||
logseq.App.showMsg("Invalid date format.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.service.editEntry(this.editDraft.entryId, {
|
||||
pageNameSnapshot,
|
||||
pageUuid: matchedPage?.uuid ?? null,
|
||||
startedAtEpochMs,
|
||||
endedAtEpochMs,
|
||||
note: this.editDraft.note
|
||||
});
|
||||
this.editDraft = null;
|
||||
this.render();
|
||||
} catch (error) {
|
||||
logseq.App.showMsg((error as Error).message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
private escapeAttribute(value: string): string {
|
||||
return this.escapeHtml(value).replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": [
|
||||
"@logseq/libs"
|
||||
],
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
target: "es2020"
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user