initialer Commit

This commit is contained in:
Eric Neuber 2026-06-26 10:04:47 +02:00
commit 35659829c9
14 changed files with 2209 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

33
README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
private escapeAttribute(value: string): string {
return this.escapeHtml(value).replace(/"/g, "&quot;");
}
}

21
tsconfig.json Normal file
View 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
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
build: {
outDir: "dist",
sourcemap: true,
target: "es2020"
}
});