From a6ca2558357b6bab8d14ed9ebc9fb5f96423771c Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Thu, 5 Nov 2026 00:42:58 -0500 Subject: [PATCH] Add Timelog: show the timer in the tab title and simplify the code This is the first commit of the project, at the state that runs on timelog.minhtrannhat.com. Changes in this state: - Remove redundant UI text from the sidebar, timer and insights pages. - Show the remaining time and tag in the browser tab title while a session runs. - Put the shared record type and the due and end helpers in src/shared/record.ts. - Remove the startup tag migration and the one-time Forest import script. The live data does not need them. - Remove unused CSS, Playwright ignore rules and duplicate helpers. - Do not track Forest CSV exports, because they contain private data. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 6 + .env.example | 14 + .gitignore | 8 + Dockerfile.api | 11 + Dockerfile.web | 15 + README.md | 50 ++++ bun.lock | 64 ++++ compose.yml | 36 +++ deploy/nginx.conf | 13 + package.json | 23 ++ scripts/backup.ts | 23 ++ scripts/build.ts | 24 ++ scripts/vapid.ts | 5 + src/api/auth.ts | 125 ++++++++ src/api/db.ts | 171 +++++++++++ src/api/server.ts | 88 ++++++ src/client/analytics.ts | 78 +++++ src/client/csv.ts | 95 ++++++ src/client/icon-180.png | Bin 0 -> 9167 bytes src/client/icon-192.png | Bin 0 -> 9721 bytes src/client/icon-512.png | Bin 0 -> 10880 bytes src/client/icon.svg | 8 + src/client/index.html | 28 ++ src/client/main.ts | 502 ++++++++++++++++++++++++++++++++ src/client/manifest.webmanifest | 16 + src/client/store.ts | 84 ++++++ src/client/styles.css | 229 +++++++++++++++ src/client/sw.js | 42 +++ src/client/sync.ts | 58 ++++ src/client/title.ts | 16 + src/shared/record.ts | 25 ++ src/shared/tag.ts | 10 + src/web-server.ts | 33 +++ tests/api.test.ts | 52 ++++ tests/csv.test.ts | 51 ++++ tests/record.test.ts | 17 ++ tests/title.test.ts | 34 +++ tsconfig.json | 15 + 38 files changed, 2069 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile.api create mode 100644 Dockerfile.web create mode 100644 README.md create mode 100644 bun.lock create mode 100644 compose.yml create mode 100644 deploy/nginx.conf create mode 100644 package.json create mode 100644 scripts/backup.ts create mode 100644 scripts/build.ts create mode 100644 scripts/vapid.ts create mode 100644 src/api/auth.ts create mode 100644 src/api/db.ts create mode 100644 src/api/server.ts create mode 100644 src/client/analytics.ts create mode 100644 src/client/csv.ts create mode 100644 src/client/icon-180.png create mode 100644 src/client/icon-192.png create mode 100644 src/client/icon-512.png create mode 100644 src/client/icon.svg create mode 100644 src/client/index.html create mode 100644 src/client/main.ts create mode 100644 src/client/manifest.webmanifest create mode 100644 src/client/store.ts create mode 100644 src/client/styles.css create mode 100644 src/client/sw.js create mode 100644 src/client/sync.ts create mode 100644 src/client/title.ts create mode 100644 src/shared/record.ts create mode 100644 src/shared/tag.ts create mode 100644 src/web-server.ts create mode 100644 tests/api.test.ts create mode 100644 tests/csv.test.ts create mode 100644 tests/record.test.ts create mode 100644 tests/title.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..451b81f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +*.sqlite* +*.csv diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6098e22 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Public URL, including https:// and no trailing slash. +PUBLIC_ORIGIN=https://timelog.minhtrannhat.com + +# Create a GitHub OAuth app with callback: +# https://timelog.minhtrannhat.com/auth/github/callback +# The ID is the stable numeric account ID from https://api.github.com/users/YOUR_LOGIN. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_ALLOWED_ID= + +# Generate these with: bun run vapid +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:you@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c063061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.sqlite +*.sqlite-shm +*.sqlite-wal +.env +.DS_Store +*.csv diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..33bd992 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,11 @@ +FROM oven/bun:1.4.2 +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production +COPY src/api ./src/api +COPY src/shared ./src/shared +COPY scripts/backup.ts ./scripts/backup.ts +RUN mkdir -p /data && chown bun:bun /data +USER bun +EXPOSE 3001 +CMD ["bun", "src/api/server.ts"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..b38d140 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,15 @@ +FROM oven/bun:1.4.2 AS build +WORKDIR /app +COPY package.json bun.lock tsconfig.json ./ +RUN bun install --frozen-lockfile +COPY scripts ./scripts +COPY src ./src +RUN bun run build + +FROM oven/bun:1.4.2 +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=build /app/src/web-server.ts ./src/web-server.ts +USER bun +EXPOSE 3000 +CMD ["bun", "src/web-server.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a678c2 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Timelog + +A private, minimal time log with a duration-first timer, offline PWA, Forest CSV import/export, and time trends by day, week, month, year, and tag. The terminal-style UI offers Catppuccin Mocha and Latte themes, with the choice saved on each device. There is no tree or streak system. + +## Run locally + +Install [Bun](https://bun.sh/) and run: + +```sh +bun install +bun run build +``` + +In two terminals, run `bun run dev:api` and `bun run dev:web`, then open . Local development signs in automatically and binds the API to `127.0.0.1`. After editing the client, run `bun run build` again and reload. Local data is stored in browser IndexedDB and `dev.sqlite` (both ignored by Git). + +`bun test` checks the supplied Forest export, CSV round-tripping, analytics, and sync behavior. `bun run typecheck` checks the TypeScript. + +## Deploy on minhtrannhat.com + +SQLite is an embedded database, so there is no useful separate SQLite server image. Compose runs two Bun images: a web server and an API that owns a SQLite file in the persistent `timelog_data` volume. Only the web container publishes a port, bound to server loopback at `127.0.0.1:8092`. + +1. Create a [GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) with homepage `https://timelog.minhtrannhat.com` and callback `https://timelog.minhtrannhat.com/auth/github/callback`. Disable callback URL wildcard matching. This app asks for no OAuth scopes and uses your stable numeric GitHub user ID as its allowlist. You can find the ID at `https://api.github.com/users/YOUR_LOGIN`. +2. Copy `.env.example` to `.env` on the server and set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, and `GITHUB_ALLOWED_ID`. Run `bun run vapid` once to generate push keys and put its three values in `.env`. Keep `.env` private (`chmod 600 .env`). +3. Copy this project to `~/apps/timelog` on the server, excluding `node_modules`, `dist`, local SQLite files, and the original Forest CSV. Run `docker compose up -d --build` there. +4. Install [deploy/nginx.conf](deploy/nginx.conf) as `/etc/nginx/sites-enabled/timelog.conf`, run `sudo nginx -t`, then `sudo systemctl reload nginx`. The server already has Nginx and Docker Compose; this config routes the subdomain to the loopback web port. Ensure the subdomain's DNS points through the existing Cloudflare setup before using the public URL. +5. Sign in at `https://timelog.minhtrannhat.com`, open Settings, and import the Forest CSV. The supplied file has 2,709 sessions; re-imports skip identical records. Export there whenever you want a Forest-shaped copy of the log. + +Do not publish the site before the OAuth credentials and allowed GitHub ID are set. The API rejects data requests without a valid session cookie and only accepts writes from `PUBLIC_ORIGIN`. + +To back up the database consistently while it is running: + +```sh +cd ~/apps/timelog +docker compose exec -T api bun scripts/backup.ts /data/timelog-backup.sqlite +docker compose cp api:/data/timelog-backup.sqlite ./timelog-backup.sqlite +``` + +The backup destination must not already exist. Store the copied file somewhere outside the server too. The Forest CSV export is a convenient second copy of completed sessions, but the SQLite backup also retains sync, auth, and notification state. + +## iPhone + +Open the HTTPS site in Safari, then use **Share → Add to Home Screen**. The installed app works offline and syncs saved changes when it is online again. Open Settings in the installed app and enable completion notifications. Timelog sends standard Web Push to the iPhone subscription endpoint, which Apple routes through its push service; no Mac or Apple Developer account is needed. If the phone is offline at the deadline, the notification can arrive later when it reconnects, but not at the exact deadline. The next app open still completes the session at its chosen end time and caps the logged duration there. + +No native iPhone widget is included. The PWA remembers the last tag and duration on each device. + +## Data model + +The timer writes actual elapsed time when stopped early and caps completed sessions at their selected duration. Manual add, edit, and delete live in the Log UI; signed-in devices exchange changes through the private sync API. The server is the durable copy, and each browser keeps an IndexedDB copy and outbox for offline work. Conflicting changes to the same entry follow last-write-wins order on the server. + +Forest import/export uses exactly `Start Time,End Time,Tag,Note,Tree Type,Is Success`. Imported tree type and original timestamp strings are kept for export, but tree type is hidden from the UI. Trends split time across local-day boundaries and use actual time for stopped-early sessions. Analytics use the timezone of the device displaying them. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f10c17f --- /dev/null +++ b/bun.lock @@ -0,0 +1,64 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "timelog", + "dependencies": { + "web-push": "^3.6.7", + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "@types/web-push": "^3.6.4", + "typescript": "^5.9.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + + "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..5fe190b --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile.web + restart: unless-stopped + environment: + WEB_PORT: "3000" + WEB_HOST: 0.0.0.0 + API_ORIGIN: http://api:3001 + ports: + - "127.0.0.1:8092:3000" + depends_on: + - api + + api: + build: + context: . + dockerfile: Dockerfile.api + restart: unless-stopped + environment: + API_PORT: "3001" + API_HOST: 0.0.0.0 + DB_PATH: /data/timelog.sqlite + PUBLIC_ORIGIN: ${PUBLIC_ORIGIN} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + GITHUB_ALLOWED_ID: ${GITHUB_ALLOWED_ID} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_SUBJECT: ${VAPID_SUBJECT} + volumes: + - timelog_data:/data + +volumes: + timelog_data: diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..8dad61b --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + listen [::]:80; + server_name timelog.minhtrannhat.com; + + location / { + proxy_pass http://127.0.0.1:8092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f8f5b09 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "timelog", + "private": true, + "type": "module", + "scripts": { + "build": "bun run scripts/build.ts", + "dev:api": "DEV_AUTH=1 PUBLIC_ORIGIN=http://localhost:3000 DB_PATH=./dev.sqlite bun --watch src/api/server.ts", + "dev:web": "WEB_PORT=3000 API_ORIGIN=http://localhost:3001 bun --watch src/web-server.ts", + "start:api": "bun src/api/server.ts", + "start:web": "bun src/web-server.ts", + "test": "bun test", + "typecheck": "bunx tsc --noEmit", + "vapid": "bun run scripts/vapid.ts" + }, + "dependencies": { + "web-push": "^3.6.7" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "@types/web-push": "^3.6.4", + "typescript": "^5.9.0" + } +} diff --git a/scripts/backup.ts b/scripts/backup.ts new file mode 100644 index 0000000..a596a95 --- /dev/null +++ b/scripts/backup.ts @@ -0,0 +1,23 @@ +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { Database } from "bun:sqlite"; + +const source = process.env.DB_PATH || "./dev.sqlite"; +const destination = Bun.argv[2]; +if (!destination) { + console.error("Usage: bun scripts/backup.ts /path/to/new-backup.sqlite"); + process.exit(1); +} +if (await Bun.file(destination).exists()) { + console.error(`Backup already exists: ${destination}`); + process.exit(1); +} + +await mkdir(dirname(destination), { recursive: true }); +const db = new Database(source); +try { + db.query("VACUUM INTO ?").run(destination); + console.log(`SQLite backup written to ${destination}`); +} finally { + db.close(); +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..a90ea5d --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,24 @@ +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; + +await mkdir("dist/assets", { recursive: true }); +const result = await Bun.build({ + entrypoints: ["src/client/main.ts"], + outdir: "dist/assets", + target: "browser", + minify: true, + naming: "app.js", +}); +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} +const buildId = Date.now().toString(36); +const html = await readFile("src/client/index.html", "utf8"); +await writeFile("dist/index.html", html.replaceAll("__BUILD_ID__", buildId)); +await copyFile("src/client/styles.css", "dist/styles.css"); +await copyFile("src/client/manifest.webmanifest", "dist/manifest.webmanifest"); +await copyFile("src/client/icon.svg", "dist/icon.svg"); +for (const size of [180, 192, 512]) await copyFile(`src/client/icon-${size}.png`, `dist/icon-${size}.png`); +const worker = await readFile("src/client/sw.js", "utf8"); +await writeFile("dist/sw.js", worker.replaceAll("__BUILD_ID__", buildId)); +console.log("Built PWA in dist/"); diff --git a/scripts/vapid.ts b/scripts/vapid.ts new file mode 100644 index 0000000..87c2c53 --- /dev/null +++ b/scripts/vapid.ts @@ -0,0 +1,5 @@ +import webpush from "web-push"; + +const keys = webpush.generateVAPIDKeys(); +console.log(`VAPID_PUBLIC_KEY=${keys.publicKey}`); +console.log(`VAPID_PRIVATE_KEY=${keys.privateKey}`); diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..e4a316c --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,125 @@ +import { db } from "./db.ts"; + +const ORIGIN = process.env.PUBLIC_ORIGIN || "http://localhost:3000"; +const DEV_AUTH = process.env.DEV_AUTH === "1" && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(ORIGIN); +const SESSION_MS = 30 * 24 * 60 * 60 * 1000; + +function randomString() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return Buffer.from(bytes).toString("base64url"); +} + +async function digest(value: string) { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Buffer.from(bytes).toString("base64url"); +} + +function cookie(request: Request, name: string) { + const values = request.headers.get("cookie") || ""; + return values.split(";").map(v => v.trim()).find(v => v.startsWith(`${name}=`))?.slice(name.length + 1); +} + +function setCookie(name: string, value: string, path: string, maxAge: number) { + const secure = ORIGIN.startsWith("https:") ? "; Secure" : ""; + return `${name}=${value}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; +} + +function sessionCookie(value: string, maxAge = Math.floor(SESSION_MS / 1000)) { + return setCookie("timelog_session", value, "/", maxAge); +} + +function stateCookie(value: string, maxAge = 600) { + return setCookie("timelog_oauth_state", value, "/auth/github/callback", maxAge); +} + +export async function isAuthenticated(request: Request) { + if (DEV_AUTH) return true; + const token = cookie(request, "timelog_session"); + if (!token) return false; + const hash = await digest(token); + const row = db.query<{ github_id: string }, [string, number]>( + "SELECT github_id FROM auth_sessions WHERE token_hash=? AND expires_ms>?" + ).get(hash, Date.now()); + return row?.github_id === process.env.GITHUB_ALLOWED_ID; +} + +export function isDevAuth() { return DEV_AUTH; } + +export function hasGithubConfig() { + return Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_ALLOWED_ID); +} + +export function isAllowedOrigin(request: Request) { + const origin = request.headers.get("origin"); + return DEV_AUTH || origin === ORIGIN; +} + +export async function beginGithubLogin() { + if (!hasGithubConfig()) return new Response("GitHub sign-in is not configured", { status: 503 }); + db.query("DELETE FROM oauth_states WHERE expires_ms( + "SELECT verifier,expires_ms FROM oauth_states WHERE state=?" + ).get(state); + db.query("DELETE FROM oauth_states WHERE state=?").run(state); + if (!row || row.expires_ms <= Date.now()) return new Response("Expired sign-in request", { status: 400 }); + + const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { "Accept": "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: `${ORIGIN}/auth/github/callback`, + code_verifier: row.verifier, + }), + }); + const tokenData = await tokenResponse.json() as { access_token?: string }; + if (!tokenResponse.ok || !tokenData.access_token) return new Response("GitHub sign-in failed", { status: 401 }); + const userResponse = await fetch("https://api.github.com/user", { + headers: { + "Authorization": `Bearer ${tokenData.access_token}`, + "Accept": "application/vnd.github+json", + "User-Agent": "Timelog", + }, + }); + const user = await userResponse.json() as { id?: number }; + if (!userResponse.ok || String(user.id) !== process.env.GITHUB_ALLOWED_ID) { + return new Response("This GitHub account is not allowed", { status: 403 }); + } + const token = randomString(); + db.query("INSERT INTO auth_sessions(token_hash,github_id,expires_ms) VALUES (?,?,?)") + .run(await digest(token), String(user.id), Date.now() + SESSION_MS); + const headers = new Headers({ Location: ORIGIN }); + headers.append("Set-Cookie", sessionCookie(token)); + headers.append("Set-Cookie", stateCookie("", 0)); + return new Response(null, { status: 302, headers }); +} + +export async function logout(request: Request) { + const token = cookie(request, "timelog_session"); + if (token) db.query("DELETE FROM auth_sessions WHERE token_hash=?").run(await digest(token)); + return new Response(null, { status: 204, headers: { "Set-Cookie": sessionCookie("", 0) } }); +} diff --git a/src/api/db.ts b/src/api/db.ts new file mode 100644 index 0000000..a5c4267 --- /dev/null +++ b/src/api/db.ts @@ -0,0 +1,171 @@ +import { Database } from "bun:sqlite"; +import type { TimeRecord } from "../shared/record.ts"; +import { normalizeTag } from "../shared/tag.ts"; + + +export type Operation = { id: string; record: TimeRecord }; + +const path = process.env.DB_PATH || "./dev.sqlite"; +export const db = new Database(path, { create: true }); +db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;"); +db.exec(` + CREATE TABLE IF NOT EXISTS records ( + id TEXT PRIMARY KEY, + start_ms INTEGER NOT NULL, + end_ms INTEGER, + target_seconds INTEGER, + tag TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + tree_type TEXT NOT NULL DEFAULT '', + is_success INTEGER, + source TEXT NOT NULL, + raw_start TEXT, + raw_end TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + revision INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS records_revision_idx ON records(revision); + CREATE INDEX IF NOT EXISTS records_due_idx ON records(end_ms, deleted, start_ms); + CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, created_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL); + INSERT OR IGNORE INTO meta(key, value) VALUES ('revision', 0); + CREATE TABLE IF NOT EXISTS auth_sessions ( + token_hash TEXT PRIMARY KEY, + github_id TEXT NOT NULL, + expires_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + verifier TEXT NOT NULL, + expires_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS push_subscriptions ( + endpoint TEXT PRIMARY KEY, + data TEXT NOT NULL, + updated_ms INTEGER NOT NULL + ); +`); + +const getRevisionQuery = db.query<{ value: number }, []>("SELECT value FROM meta WHERE key='revision'"); +const setRevisionQuery = db.query("UPDATE meta SET value=? WHERE key='revision'"); +const existingOperationQuery = db.query<{ id: string }, [string]>("SELECT id FROM operations WHERE id=?"); +const existingRecordQuery = db.query<{ id: string }, [string]>("SELECT id FROM records WHERE id=?"); +const insertOperationQuery = db.query("INSERT INTO operations(id,created_ms) VALUES (?,?)"); +const upsertRecordQuery = db.query(` + INSERT INTO records(id,start_ms,end_ms,target_seconds,tag,note,tree_type,is_success,source,raw_start,raw_end,deleted,revision) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + start_ms=excluded.start_ms,end_ms=excluded.end_ms,target_seconds=excluded.target_seconds, + tag=excluded.tag,note=excluded.note,tree_type=excluded.tree_type,is_success=excluded.is_success, + source=excluded.source,raw_start=excluded.raw_start,raw_end=excluded.raw_end, + deleted=excluded.deleted,revision=excluded.revision +`); + +function nextRevision() { + const next = getRevisionQuery.get()!.value + 1; + setRevisionQuery.run(next); + return next; +} + +const ID_PATTERN = /^[a-zA-Z0-9_-]{8,100}$/; + +function validText(value: unknown, max: number): value is string { + return typeof value === "string" && value.length <= max; +} + +function validateRecord(value: unknown): value is TimeRecord { + if (!value || typeof value !== "object") return false; + const r = value as Partial; + return typeof r.id === "string" && ID_PATTERN.test(r.id) + && Number.isSafeInteger(r.startMs) && r.startMs! > 0 + && (r.endMs === null || (Number.isSafeInteger(r.endMs) && r.endMs! >= r.startMs!)) + && (r.targetSeconds === null || (Number.isInteger(r.targetSeconds) && r.targetSeconds! >= 1 && r.targetSeconds! <= 86400)) + && validText(r.tag, 100) && r.tag.trim().length > 0 + && validText(r.note, 2000) && validText(r.treeType, 100) + && (r.isSuccess === null || typeof r.isSuccess === "boolean") + && (r.source === "timer" || r.source === "forest" || r.source === "manual") + && (r.rawStart === null || validText(r.rawStart, 80)) + && (r.rawEnd === null || validText(r.rawEnd, 80)) + && typeof r.deleted === "boolean"; +} + +export function validateOperation(value: unknown): value is Operation { + if (!value || typeof value !== "object") return false; + const op = value as Partial; + return typeof op.id === "string" && ID_PATTERN.test(op.id) && validateRecord(op.record); +} + +export function applyOperations(operations: Operation[]): string[] { + const run = db.transaction((items: Operation[]) => { + const accepted: string[] = []; + for (const op of items) { + if (existingOperationQuery.get(op.id)) { + accepted.push(op.id); + continue; + } + const r = op.record; + if (r.source === "forest" && existingRecordQuery.get(r.id)) { + insertOperationQuery.run(op.id, Date.now()); + accepted.push(op.id); + continue; + } + const revision = nextRevision(); + upsertRecordQuery.run(r.id, r.startMs, r.endMs, r.targetSeconds, normalizeTag(r.tag), r.note, + r.treeType, r.isSuccess === null ? null : Number(r.isSuccess), r.source, + r.rawStart, r.rawEnd, Number(r.deleted), revision); + insertOperationQuery.run(op.id, Date.now()); + accepted.push(op.id); + } + return accepted; + }); + return run(operations); +} + +type DbRecord = { + id: string; start_ms: number; end_ms: number | null; target_seconds: number | null; + tag: string; note: string; tree_type: string; is_success: number | null; + source: TimeRecord["source"]; raw_start: string | null; raw_end: string | null; + deleted: number; revision: number; +}; + +function fromDb(r: DbRecord): TimeRecord { + return { + id: r.id, startMs: r.start_ms, endMs: r.end_ms, targetSeconds: r.target_seconds, + tag: r.tag, note: r.note, treeType: r.tree_type, + isSuccess: r.is_success === null ? null : Boolean(r.is_success), source: r.source, + rawStart: r.raw_start, rawEnd: r.raw_end, deleted: Boolean(r.deleted), revision: r.revision, + }; +} + +export function changesSince(since: number, limit = 1000) { + const rows = db.query( + "SELECT * FROM records WHERE revision>? ORDER BY revision LIMIT ?" + ).all(since, limit); + const records = rows.map(fromDb); + return { + records, + cursor: records.length ? records[records.length - 1]!.revision! : since, + hasMore: records.length === limit, + }; +} + +const dueQuery = db.query( + "SELECT * FROM records WHERE deleted=0 AND end_ms IS NULL AND target_seconds IS NOT NULL AND start_ms + target_seconds * 1000 <= ?" +); +const finishQuery = db.query("UPDATE records SET end_ms=?,is_success=1,revision=? WHERE id=?"); + +export function finishDue(now = Date.now()): TimeRecord[] { + const run = db.transaction(() => { + const due = dueQuery.all(now); + for (const r of due) { + const end = r.start_ms + r.target_seconds! * 1000; + const revision = nextRevision(); + finishQuery.run(end, revision, r.id); + r.end_ms = end; + r.is_success = 1; + r.revision = revision; + } + return due.map(fromDb); + }); + return run(); +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..97db94e --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,88 @@ +import webpush from "web-push"; +import { db, applyOperations, changesSince, finishDue, validateOperation } from "./db.ts"; +import { beginGithubLogin, finishGithubLogin, hasGithubConfig, isAllowedOrigin, isAuthenticated, isDevAuth, logout } from "./auth.ts"; + +const API_PORT = Number(process.env.API_PORT || 3001); +const vapidReady = Boolean(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT); +if (vapidReady) { + webpush.setVapidDetails(process.env.VAPID_SUBJECT!, process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!); +} + +function json(data: unknown, status = 200) { + return Response.json(data, { status, headers: { "Cache-Control": "no-store" } }); +} + +async function sendFinishPush(id: string, tag: string) { + if (!vapidReady) return; + const rows = db.query<{ endpoint: string; data: string }, []>("SELECT endpoint,data FROM push_subscriptions").all(); + for (const row of rows) { + try { + await webpush.sendNotification(JSON.parse(row.data), JSON.stringify({ + title: "Session complete", body: `${tag} time is up.`, tag: `timelog-${id}`, url: "/", + }), { TTL: 3600, urgency: "high" }); + } catch (error) { + const code = (error as { statusCode?: number }).statusCode; + if (code === 404 || code === 410) db.query("DELETE FROM push_subscriptions WHERE endpoint=?").run(row.endpoint); + else console.error("Push failed", code || error); + } + } +} + +function reconcileDue() { + const done = finishDue(); + for (const record of done) void sendFinishPush(record.id, record.tag); +} +reconcileDue(); +setInterval(reconcileDue, 1000); + +Bun.serve({ + port: API_PORT, + hostname: isDevAuth() ? "127.0.0.1" : (process.env.API_HOST || "0.0.0.0"), + async fetch(request) { + try { + const url = new URL(request.url); + const path = url.pathname; + if (path === "/api/health") return json({ ok: true }); + if (path === "/auth/github" && request.method === "GET") return beginGithubLogin(); + if (path === "/auth/github/callback" && request.method === "GET") return finishGithubLogin(request); + if (path === "/api/me" && request.method === "GET") { + return json({ authenticated: await isAuthenticated(request), dev: isDevAuth(), + githubConfigured: hasGithubConfig(), vapidPublicKey: vapidReady ? process.env.VAPID_PUBLIC_KEY : null }); + } + if (!path.startsWith("/api/")) return json({ error: "Not found" }, 404); + if (!await isAuthenticated(request)) return json({ error: "Sign in required" }, 401); + if (!["GET", "HEAD"].includes(request.method) && !isAllowedOrigin(request)) { + return json({ error: "Invalid request origin" }, 403); + } + if (path === "/api/logout" && request.method === "POST") return logout(request); + if (path === "/api/state" && request.method === "GET") { + reconcileDue(); + const since = Number(url.searchParams.get("since") || 0); + if (!Number.isSafeInteger(since) || since < 0) return json({ error: "Invalid cursor" }, 400); + return json(changesSince(since)); + } + if (path === "/api/operations" && request.method === "POST") { + const payload = await request.json() as { operations?: unknown }; + if (!Array.isArray(payload.operations) || payload.operations.length > 500 || + !payload.operations.every(validateOperation)) return json({ error: "Invalid operations" }, 400); + const accepted = applyOperations(payload.operations); + reconcileDue(); + return json({ accepted }); + } + if (path === "/api/push/subscribe" && request.method === "POST") { + if (!vapidReady) return json({ error: "Notifications are not configured" }, 503); + const subscription = await request.json() as { endpoint?: unknown }; + if (typeof subscription.endpoint !== "string" || !subscription.endpoint.startsWith("https://") || + subscription.endpoint.length > 2048) return json({ error: "Invalid subscription" }, 400); + db.query("INSERT INTO push_subscriptions(endpoint,data,updated_ms) VALUES (?,?,?) ON CONFLICT(endpoint) DO UPDATE SET data=excluded.data,updated_ms=excluded.updated_ms") + .run(subscription.endpoint, JSON.stringify(subscription), Date.now()); + return json({ ok: true }); + } + return json({ error: "Not found" }, 404); + } catch (error) { + console.error(error); + return json({ error: "Server error" }, 500); + } + }, +}); +console.log(`Timelog API listening on ${API_PORT}`); diff --git a/src/client/analytics.ts b/src/client/analytics.ts new file mode 100644 index 0000000..011bece --- /dev/null +++ b/src/client/analytics.ts @@ -0,0 +1,78 @@ +import { recordEnd, type TimeRecord } from "../shared/record.ts"; + +export type RangeKind = "day" | "week" | "month" | "year"; +export type Window = { start: number; end: number; label: string; bins: { start: number; end: number; label: string }[] }; + +export function overlapMs(record: TimeRecord, start: number, end: number, now = Date.now()): number { + if (record.deleted) return 0; + return Math.max(0, Math.min(recordEnd(record, now), end) - Math.max(record.startMs, start)); +} + +function dayStart(value: Date): Date { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()); +} + +export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Window { + let startDate: Date; + let endDate: Date; + const bins: Window["bins"] = []; + if (kind === "day") { + startDate = dayStart(base); + startDate.setDate(startDate.getDate() + offset); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 1); + for (let hour = 0; hour < 24; hour += 2) { + const a = new Date(startDate); a.setHours(hour); + const b = new Date(startDate); b.setHours(hour + 2); + bins.push({ start: +a, end: +b, label: String(hour).padStart(2, "0") }); + } + } else if (kind === "week") { + startDate = dayStart(base); + startDate.setDate(startDate.getDate() - (startDate.getDay() + 6) % 7 + offset * 7); + endDate = new Date(startDate); endDate.setDate(endDate.getDate() + 7); + for (let day = 0; day < 7; day++) { + const a = new Date(startDate); a.setDate(a.getDate() + day); + const b = new Date(a); b.setDate(b.getDate() + 1); + bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 2) }); + } + } else if (kind === "month") { + startDate = new Date(base.getFullYear(), base.getMonth() + offset, 1); + endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 1); + for (let day = 1; day <= new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0).getDate(); day++) { + const a = new Date(startDate.getFullYear(), startDate.getMonth(), day); + const b = new Date(startDate.getFullYear(), startDate.getMonth(), day + 1); + bins.push({ start: +a, end: +b, label: day % 5 === 1 ? String(day) : "" }); + } + } else { + startDate = new Date(base.getFullYear() + offset, 0, 1); + endDate = new Date(startDate.getFullYear() + 1, 0, 1); + for (let month = 0; month < 12; month++) { + const a = new Date(startDate.getFullYear(), month, 1); + const b = new Date(startDate.getFullYear(), month + 1, 1); + bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { month: "short" }).slice(0, 1) }); + } + } + const label = kind === "day" ? startDate.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) + : kind === "week" ? `${startDate.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${new Date(+endDate - 1).toLocaleDateString(undefined, { month: "short", day: "numeric" })}` + : kind === "month" ? startDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }) + : String(startDate.getFullYear()); + return { start: +startDate, end: +endDate, label, bins }; +} + +export function totals(records: TimeRecord[], window: Window, now = Date.now()) { + const trend = window.bins.map(bin => records.reduce((sum, record) => sum + overlapMs(record, bin.start, bin.end, now), 0)); + const byTag = new Map(); + let sessionCount = 0; + for (const record of records) { + const duration = overlapMs(record, window.start, window.end, now); + if (duration <= 0) continue; + sessionCount++; + byTag.set(record.tag, (byTag.get(record.tag) || 0) + duration); + } + return { + totalMs: [...byTag.values()].reduce((a, b) => a + b, 0), + sessionCount, + trend, + tags: [...byTag].sort((a, b) => b[1] - a[1]), + }; +} diff --git a/src/client/csv.ts b/src/client/csv.ts new file mode 100644 index 0000000..6dee9ff --- /dev/null +++ b/src/client/csv.ts @@ -0,0 +1,95 @@ +import type { TimeRecord } from "../shared/record.ts"; +import { normalizeTag } from "../shared/tag.ts"; + + +const FOREST_HEADERS = ["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"]; + +export function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + const input = text.replace(/^\uFEFF/, ""); + for (let i = 0; i < input.length; i++) { + const char = input[i]!; + if (quoted) { + if (char === '"' && input[i + 1] === '"') { field += '"'; i++; } + else if (char === '"') quoted = false; + else field += char; + } else if (char === '"' && field === "") { + quoted = true; + } else if (char === ",") { + row.push(field); field = ""; + } else if (char === "\n" || char === "\r") { + if (char === "\r" && input[i + 1] === "\n") i++; + row.push(field); + if (row.some(value => value !== "")) rows.push(row); + row = []; field = ""; + } else { + field += char; + } + } + if (quoted) throw new Error("CSV has an unfinished quoted field"); + row.push(field); + if (row.some(value => value !== "")) rows.push(row); + return rows; +} + +function parseForestDate(value: string): number { + const normalized = value.trim().replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); + const ms = Date.parse(normalized); + if (!Number.isFinite(ms)) throw new Error(`Invalid Forest timestamp: ${value}`); + return ms; +} + +function hashHex(bytes: ArrayBuffer) { + return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join(""); +} + +export async function importForestCsv(text: string): Promise { + const rows = parseCsv(text); + if (!rows.length || FOREST_HEADERS.some((header, index) => rows[0]![index]?.trim() !== header)) { + throw new Error("This is not a Forest CSV with the expected six columns"); + } + const records: TimeRecord[] = []; + for (const [index, row] of rows.slice(1).entries()) { + if (row.length !== 6) throw new Error(`Row ${index + 2} has ${row.length} columns; expected 6`); + const [rawStart, rawEnd, tag, note, treeType, rawSuccess] = row as [string, string, string, string, string, string]; + const startMs = parseForestDate(rawStart); + const endMs = parseForestDate(rawEnd); + if (endMs < startMs) throw new Error(`Row ${index + 2} ends before it starts`); + if (rawSuccess !== "True" && rawSuccess !== "False") throw new Error(`Row ${index + 2} has an invalid success value`); + const fingerprint = [rawStart, rawEnd, tag, note, treeType, rawSuccess].join("\u001f"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(fingerprint)); + records.push({ + id: `forest_${hashHex(digest).slice(0, 32)}`, + startMs, endMs, targetSeconds: null, tag: normalizeTag(tag) || "Unset", note, + treeType, isSuccess: rawSuccess === "True", source: "forest", + rawStart, rawEnd, deleted: false, + }); + } + return records; +} + +function forestDate(ms: number) { + const date = new Date(ms); + const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + const offset = -date.getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + const offsetText = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}${pad(Math.abs(offset) % 60)}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offsetText}`; +} + +function quote(value: string) { + return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; +} + +export function exportForestCsv(records: TimeRecord[]): string { + const lines = [FOREST_HEADERS.join(",")]; + for (const record of records.filter(r => !r.deleted && r.endMs !== null).sort((a, b) => a.startMs - b.startMs)) { + const values = [record.rawStart || forestDate(record.startMs), record.rawEnd || forestDate(record.endMs!), + record.tag, record.note, record.treeType, record.isSuccess ? "True" : "False"]; + lines.push(values.map(quote).join(",")); + } + return lines.join("\r\n") + "\r\n"; +} diff --git a/src/client/icon-180.png b/src/client/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..8e78f270fcee0445b901e4d1b629094f4b3e4780 GIT binary patch literal 9167 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 O090ORD*aTj4E}Fj^<;Pe literal 0 HcmV?d00001 diff --git a/src/client/icon-192.png b/src/client/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..db62ffea0a23385442eba091a9ebab43391b95a5 GIT binary patch literal 9721 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 literal 0 HcmV?d00001 diff --git a/src/client/icon.svg b/src/client/icon.svg new file mode 100644 index 0000000..dd5de35 --- /dev/null +++ b/src/client/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..8785a34 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + Timelog + + +
+ + + diff --git a/src/client/main.ts b/src/client/main.ts new file mode 100644 index 0000000..37284c7 --- /dev/null +++ b/src/client/main.ts @@ -0,0 +1,502 @@ +import { exportForestCsv, importForestCsv } from "./csv.ts"; +import { overlapMs, rangeWindow, totals, type RangeKind } from "./analytics.ts"; +import { allRecords, saveRecord, saveRecords } from "./store.ts"; +import { syncNow, type SyncStatus } from "./sync.ts"; +import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts"; +import { normalizeTag } from "../shared/tag.ts"; +import { countdown, pad, pageTitle } from "./title.ts"; + +type View = "timer" | "log" | "insights" | "settings"; +type Theme = "latte" | "mocha"; +type Me = { authenticated: boolean; dev: boolean; githubConfigured: boolean; vapidPublicKey: string | null }; + +const app = document.getElementById("app")!; +let records: TimeRecord[] = []; +let view: View = "timer"; +let theme: Theme = document.documentElement.dataset.theme === "latte" ? "latte" : "mocha"; +let me: Me | null = null; +let syncStatus: SyncStatus = navigator.onLine ? "pending" : "offline"; +let selectedTag = normalizeTag(localStorage.getItem("timelog-last-tag") || "Study"); +let selectedDuration = Number(localStorage.getItem("timelog-last-duration") || 25); +let logMonthOffset = 0; +let logTag = "all"; +let insightKind: RangeKind = "week"; +let insightOffset = 0; +let editingId: string | null = null; +let message: { text: string; kind: "success" | "error" } | null = null; +let toastTimer = 0; +let pushRegistration: ServiceWorkerRegistration | null = null; + +function h(value: unknown): string { + return String(value ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); +} + +function clock(ms: number) { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } +function dateLabel(ms: number) { return new Date(ms).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); } +function shortDate(ms: number) { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } +function duration(ms: number) { + if (ms <= 0) return "0m"; + const minutes = Math.round(ms / 60000); + if (minutes <= 0) return "<1m"; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return hours ? `${hours}h${rest ? ` ${rest}m` : ""}` : `${rest}m`; +} +function datetimeLocal(ms: number) { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} +function tags() { + return [...new Set([selectedTag, "Study", ...records.filter(r => !r.deleted).map(r => r.tag)])].sort((a, b) => a.localeCompare(b)); +} +function activeRecord() { return records.find(r => !r.deleted && r.endMs === null) || null; } +function progress(r: TimeRecord) { return Math.min(100, Math.max(0, (Date.now() - r.startMs) / (dueMs(r) - r.startMs) * 100)); } +function statusText() { + return ({ offline: "OFFLINE · SAVED HERE", syncing: "SYNCING...", synced: "ALL SYNCED", pending: "SYNC PENDING", "signed-out": "SIGN IN TO SYNC" } as const)[syncStatus]; +} +function markSignedOut() { + me = { ...(me || { dev: false, githubConfigured: true, vapidPublicKey: null }), authenticated: false }; + localStorage.removeItem("timelog-auth-seen"); + syncStatus = "signed-out"; +} +function setTheme(next: Theme) { + theme = next; + document.documentElement.dataset.theme = next; + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "latte" ? "#e6e9ef" : "#1e1e2e"); + localStorage.setItem("timelog-theme", next); + render(); +} +function showMessage(text: string, kind: "success" | "error" = "success") { + message = { text, kind }; + document.querySelector(".toast")?.remove(); + const toast = document.createElement("div"); + toast.className = "toast"; + toast.setAttribute("role", "status"); + toast.textContent = text; + app.appendChild(toast); + clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { message = null; document.querySelector(".toast")?.remove(); }, 5000); +} + +function navButton(target: View, label: string, index: string) { + return ``; +} + +function navButtons() { + return `${navButton("timer", "Timer", "01")}${navButton("log", "Log", "02")}${navButton("insights", "Insights", "03")}`; +} + +function frame(content: string) { + const today = new Date().toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric", year: "numeric" }); + return `
+ +
+
${h(today)}
${statusText()}
+ ${content} +
+ +
${editingId ? editorModal() : ""}${message ? `
${h(message.text)}
` : ""}`; +} + +function loginPage() { + const offline = !navigator.onLine; + return ``; +} + +function pageHeading(title: string, meta = "", eyebrow = "") { + return `
${eyebrow ? `

${eyebrow}

` : ""}

${title}

${meta ? `${meta}` : ""}
`; +} + +function timerPage() { + const active = activeRecord(); + const todayWindow = rangeWindow("day"); + const today = totals(records, todayWindow); + const completed = records.filter(r => !r.deleted && r.endMs !== null && overlapMs(r, todayWindow.start, todayWindow.end) > 0); + const completedToday = completed.length; + const week = totals(records, rangeWindow("week")); + const tagOptions = tags().map(tag => ``).join(""); + const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6); + const weekMax = Math.max(...week.trend, 1); + return `
+
+
+ ${active ? `
● SESSION IN PROGRESS${h(active.tag.toUpperCase())}
` : ""} +
${active ? countdown(dueMs(active) - Date.now()) : `${pad(selectedDuration)}:00`}
+ ${active ? `
Started ${clock(active.startMs)} · Ends ${clock(dueMs(active))}
` : ""} +
+ ${active ? `
TAG
${h(active.tag)}
DURATION
${duration(active.targetSeconds! * 1000)}
+ ` : ` +
+
+
${[15, 25, 30, 45, 60, 120].map(n => ``).join("")}
+ `} +
+
// TODAY${h(shortDate(todayWindow.start).toUpperCase())}
+
${duration(today.totalMs)}
${completedToday} ${completedToday === 1 ? "session" : "sessions"} logged${active ? " · 1 running" : ""}
+
THIS WEEK${duration(week.totalMs)}
+
${week.trend.map((value, i) => `
`).join("")}
+
MONSUN
+
+
+

Today's log

+ ${latest.length ? latest.map(record => entryRow(record)).join("") : `
No time logged today. Start when you're ready.
`} +
+
`; +} + +function entryRow(record: TimeRecord, editable = false) { + const end = recordEnd(record); + return `
${clock(record.startMs)}–${record.endMs === null ? "NOW" : clock(end)} + + ${duration(end - record.startMs)} + ${editable ? `` : `${record.isSuccess === false ? "○" : "·"}`} +
`; +} + +function monthDate(offset: number) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth() + offset, 1); } +function logPage() { + const month = monthDate(logMonthOffset); + const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1); + const filtered = records.filter(r => !r.deleted && r.startMs >= +month && r.startMs < +nextMonth && (logTag === "all" || r.tag === logTag)) + .sort((a, b) => b.startMs - a.startMs); + const groups = new Map(); + for (const record of filtered) { + const key = new Date(record.startMs).toDateString(); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(record); + } + const tagOptions = tags().map(tag => ``).join(""); + return `
${pageHeading("Your time, in order.", `${filtered.length} ENTRIES`)} +
+ ${h(month.toLocaleDateString(undefined, { month: "long", year: "numeric" }).toUpperCase())} + + +
+ ${groups.size ? [...groups].map(([label, entries]) => `
${h(label.toUpperCase())}${duration(entries.reduce((sum, record) => sum + Math.max(0, recordEnd(record) - record.startMs), 0))}
${entries.map(record => entryRow(record, true)).join("")}
`).join("") : `
No entries for this month${logTag !== "all" ? " and tag" : ""}.
`} +
`; +} + +function insightsPage() { + const range = rangeWindow(insightKind, insightOffset); + const stats = totals(records, range); + const max = Math.max(...stats.trend, 1); + const maxTag = stats.tags[0]?.[1] || 1; + return `
${pageHeading("Where time goes.")} +
${(["day", "week", "month", "year"] as RangeKind[]).map(kind => ``).join("")}
+ + ${h(range.label.toUpperCase())}
+
// TIME TREND${h(insightKind.toUpperCase())}
+
${duration(stats.totalMs)}
${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period
+ +
// BY TAG${stats.tags.length} CATEGORIES
+ ${stats.tags.length ? stats.tags.map(([tag, value]) => `
${h(tag)}${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%
`).join("") : `
No time logged in this period.
`} +

Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.

+
+
`; +} + +function settingsPage() { + const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window; + const permission = "Notification" in window ? Notification.permission : "unsupported"; + return `
${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")} +
// DATA & DEVICEPRIVATE
+

Appearance

Catppuccin colors. Saved on this device.

+

Forest CSV import

Bring in your existing sessions. Re-importing the same file skips duplicates.

+

Forest CSV export

Download your completed sessions with Forest's six-column layout.

+

Completion notifications

${permission === "denied" ? "Blocked in your device settings." : !canNotify ? "On iPhone, add Timelog to your Home Screen, then open it there to enable alerts." : permission === "granted" ? "Permission granted on this device. Reconnect if alerts stop arriving." : "Get an alert when a session reaches its duration. On iPhone, open Timelog from your Home Screen first."}

+

Sync

${statusText()}. Sessions remain here until the server can receive them.

+

Account

${me?.dev ? "Local development mode" : "Signed in with your GitHub account"}

+
+
`; +} + +function editorModal() { + const existing = records.find(r => r.id === editingId); + const start = existing?.startMs || Date.now() - 25 * 60000; + const end = existing?.endMs || Date.now(); + return ``; +} + +function render() { + if (!me) { + app.innerHTML = ``; + return; + } + if (me && !me.authenticated && !(localStorage.getItem("timelog-auth-seen") && !navigator.onLine)) { + app.innerHTML = loginPage(); return; + } + updateTitle(); + const content = view === "timer" ? timerPage() : view === "log" ? logPage() : view === "insights" ? insightsPage() : settingsPage(); + app.innerHTML = frame(content); +} + +function updateTitle() { + const next = pageTitle(activeRecord()); + if (document.title !== next) document.title = next; +} + +function refreshFromStore() { return allRecords().then(all => { records = all; render(); }); } + +function sync() { + void syncNow((status, updated) => { + syncStatus = status; + if (updated) records = updated; + if (status === "signed-out") markSignedOut(); + render(); + }); +} + +async function notifyFinished(record: TimeRecord) { + if (!("Notification" in window) || Notification.permission !== "granted" || !("serviceWorker" in navigator)) return; + try { + const registration = pushRegistration || await navigator.serviceWorker.ready; + // The server already pushes completed sessions it knows about. Showing a + // local notification too produces duplicate alerts on the same device. + if (record.revision && await registration.pushManager?.getSubscription()) return; + await registration.showNotification("Session complete", { + body: `${record.tag} time is up.`, tag: `timelog-${record.id}`, icon: "/icon-192.png", + }); + } catch (error) { console.warn("Local notification failed", error); } +} + +async function settleDue() { + const now = Date.now(); + const due = records.filter(r => !r.deleted && r.endMs === null && r.targetSeconds !== null && dueMs(r) <= now); + if (!due.length) return; + for (const record of due) { + record.endMs = dueMs(record); + record.isSuccess = true; + await saveRecord(record); + void notifyFinished(record); + } + showMessage(due.length === 1 ? "Session complete. Time logged." : `${due.length} sessions complete. Time logged.`); + sync(); +} + +async function startSession() { + if (activeRecord()) return; + const tag = normalizeTag(selectedTag); + const minutes = Number((document.getElementById("timer-duration") as HTMLInputElement | null)?.value ?? selectedDuration); + selectedDuration = minutes; + if (!tag || !Number.isInteger(minutes) || minutes < 1 || minutes > 180) { showMessage("Choose a tag and a duration from 1 to 180 minutes.", "error"); return; } + const record: TimeRecord = { + id: `session_${crypto.randomUUID()}`, startMs: Date.now(), endMs: null, targetSeconds: minutes * 60, + tag, note: "", treeType: "", isSuccess: null, source: "timer", rawStart: null, rawEnd: null, deleted: false, + }; + localStorage.setItem("timelog-last-tag", tag); + localStorage.setItem("timelog-last-duration", String(minutes)); + await saveRecord(record); + await refreshFromStore(); + sync(); +} + +async function stopSession() { + const record = activeRecord(); + if (!record) return; + const due = dueMs(record); + record.endMs = Math.min(Date.now(), due); + record.isSuccess = record.endMs >= due; + await saveRecord(record); + await refreshFromStore(); + showMessage(`Logged ${duration(record.endMs - record.startMs)} for ${record.tag}.`); + sync(); +} + +async function saveEditor() { + const tag = normalizeTag((document.getElementById("edit-tag") as HTMLInputElement).value); + const startMs = new Date((document.getElementById("edit-start") as HTMLInputElement).value).getTime(); + const endMs = new Date((document.getElementById("edit-end") as HTMLInputElement).value).getTime(); + const note = (document.getElementById("edit-note") as HTMLInputElement).value; + if (!tag || !Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) { + showMessage("Enter a tag and valid start and end times.", "error"); return; + } + const existing = records.find(r => r.id === editingId); + const record: TimeRecord = existing ? { ...existing } : { + id: `manual_${crypto.randomUUID()}`, startMs, endMs, targetSeconds: null, tag, note, + treeType: "", isSuccess: true, source: "manual", rawStart: null, rawEnd: null, deleted: false, + }; + record.startMs = startMs; record.endMs = endMs; record.tag = tag; record.note = note; + record.rawStart = null; record.rawEnd = null; + if (record.source === "forest") record.source = "manual"; + if (record.targetSeconds !== null) record.isSuccess = endMs >= dueMs(record); + await saveRecord(record); + editingId = null; + await refreshFromStore(); + showMessage("Entry saved."); + sync(); +} + +async function deleteEditor() { + const record = records.find(r => r.id === editingId); + if (!record || !window.confirm("Delete this entry?")) return; + await saveRecord({ ...record, deleted: true }); + editingId = null; + await refreshFromStore(); + showMessage("Entry deleted."); + sync(); +} + +async function importCsvFile(file: File) { + try { + const incoming = await importForestCsv(await file.text()); + const existing = new Set(records.map(r => r.id)); + const fresh = incoming.filter(r => !existing.has(r.id)); + await saveRecords(fresh); + await refreshFromStore(); + showMessage(`Imported ${fresh.length} sessions. ${incoming.length - fresh.length} already in your log.`); + sync(); + } catch (error) { showMessage(error instanceof Error ? error.message : "Import failed", "error"); } +} + +function exportCsvFile() { + const blob = new Blob([exportForestCsv(records)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `Timelog_${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function vapidBytes(key: string) { + const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - key.length % 4) % 4); + return Uint8Array.from(atob(base64), char => char.charCodeAt(0)); +} + +async function enableNotifications() { + if (!("Notification" in window) || !pushRegistration?.pushManager) { showMessage("Open the installed app to enable notifications.", "error"); return; } + if (!me?.vapidPublicKey) { showMessage("Server push is not available right now.", "error"); return; } + try { + // Start the subscription from the tap so iOS can show its permission prompt. + const subscription = await pushRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidBytes(me.vapidPublicKey) }); + const response = await fetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription) }); + if (!response.ok) throw new Error("Could not connect notifications to the server"); + showMessage("Completion notifications enabled."); + } catch (error) { showMessage(error instanceof Error ? error.message : "Could not enable notifications", "error"); } +} + +async function dispatch(action: string, element: HTMLElement) { + switch (action) { + case "nav": view = element.dataset.view as View; editingId = null; render(); break; + case "duration": selectedDuration = Number(element.dataset.minutes); render(); break; + case "start": await startSession(); break; + case "stop": await stopSession(); break; + case "prev-log": logMonthOffset--; render(); break; + case "next-log": logMonthOffset++; render(); break; + case "prev-insight": insightOffset--; render(); break; + case "next-insight": insightOffset++; render(); break; + case "range": insightKind = element.dataset.range as RangeKind; insightOffset = 0; render(); break; + case "theme": setTheme(element.dataset.theme === "latte" ? "latte" : "mocha"); break; + case "add": editingId = "new"; render(); break; + case "edit": editingId = element.dataset.id || null; render(); break; + case "close-modal": editingId = null; render(); break; + case "save-edit": await saveEditor(); break; + case "delete-edit": await deleteEditor(); break; + case "import": (document.getElementById("csv-file") as HTMLInputElement).click(); break; + case "export": exportCsvFile(); break; + case "notify": await enableNotifications(); break; + case "sync": sync(); break; + case "logout": + if (navigator.onLine) await fetch("/api/logout", { method: "POST" }); + markSignedOut(); render(); break; + } +} + +app.addEventListener("click", event => { + const element = (event.target as HTMLElement).closest("[data-action]"); + if (element) void dispatch(element.dataset.action!, element).catch(error => showMessage(String(error), "error")); + else if ((event.target as HTMLElement).classList.contains("modal-backdrop")) { editingId = null; render(); } +}); + +app.addEventListener("change", event => { + const target = event.target as HTMLInputElement | HTMLSelectElement; + if (target.id === "timer-tag") { + if (target.value === "__new__") { + const answer = window.prompt("New tag name")?.trim(); + if (answer && answer.length <= 100) selectedTag = normalizeTag(answer); + } else selectedTag = target.value; + render(); + } else if (target.id === "timer-duration") { + selectedDuration = Number(target.value); + } else if (target.id === "log-tag") { + logTag = target.value; render(); + } else if (target.id === "csv-file") { + const file = (target as HTMLInputElement).files?.[0]; + if (file) void importCsvFile(file); + } +}); + +app.addEventListener("input", event => { + const target = event.target as HTMLInputElement; + if (target.id !== "timer-duration") return; + selectedDuration = Number(target.value); + const display = document.getElementById("timer-display"); + if (display) display.textContent = Number.isFinite(selectedDuration) ? countdown(selectedDuration * 60_000) : "--:--"; + document.querySelectorAll(".chip[data-minutes]").forEach(chip => { + chip.classList.toggle("active", Number(chip.dataset.minutes) === selectedDuration); + }); +}); + +window.addEventListener("online", () => { syncStatus = "pending"; sync(); }); +window.addEventListener("offline", () => { syncStatus = "offline"; render(); }); +document.addEventListener("visibilitychange", () => { if (!document.hidden) { void settleDue(); sync(); } }); +document.addEventListener("keydown", event => { if (event.key === "Escape" && editingId) { editingId = null; render(); } }); + +setInterval(() => { + void settleDue(); + updateTitle(); + const active = activeRecord(); + if (!active || view !== "timer") return; + const display = document.getElementById("timer-display"); + if (display) display.textContent = countdown(dueMs(active) - Date.now()); + const fill = document.getElementById("progress-fill"); + if (fill) fill.style.width = `${progress(active)}%`; + const total = document.getElementById("today-total"); + if (total) total.textContent = duration(totals(records, rangeWindow("day")).totalMs); +}, 1000); + +async function initialize() { + records = await allRecords(); + render(); + if ("serviceWorker" in navigator) { + try { + await navigator.serviceWorker.register("/sw.js"); + void navigator.serviceWorker.ready.then(registration => { pushRegistration = registration; render(); }); + } catch (error) { console.warn("Service worker registration failed", error); } + } + try { + const response = await fetch("/api/me", { cache: "no-store" }); + me = await response.json() as Me; + if (me.authenticated) localStorage.setItem("timelog-auth-seen", "1"); + if (me.authenticated) sync(); + } catch { + me = { authenticated: Boolean(localStorage.getItem("timelog-auth-seen")), dev: false, githubConfigured: true, vapidPublicKey: null }; + syncStatus = "offline"; + } + await settleDue(); + render(); +} +void initialize().catch(error => { app.innerHTML = ``; }); diff --git a/src/client/manifest.webmanifest b/src/client/manifest.webmanifest new file mode 100644 index 0000000..0c5233a --- /dev/null +++ b/src/client/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "id": "/", + "name": "Timelog", + "short_name": "Timelog", + "description": "A quiet place to log your time.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1e1e2e", + "theme_color": "#1e1e2e", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/src/client/store.ts b/src/client/store.ts new file mode 100644 index 0000000..caedd0d --- /dev/null +++ b/src/client/store.ts @@ -0,0 +1,84 @@ +import type { TimeRecord } from "../shared/record.ts"; + +type OutboxItem = { id: string; createdAt: number; record: TimeRecord }; +const DATABASE_NAME = "timelog-local-v1"; + +let opened: Promise | null = null; +function database(): Promise { + if (opened) return opened; + opened = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore("records", { keyPath: "id" }); + db.createObjectStore("outbox", { keyPath: "id" }); + db.createObjectStore("meta", { keyPath: "key" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return opened; +} + +function done(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +function result(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function allRecords(): Promise { + const db = await database(); + return result(db.transaction("records").objectStore("records").getAll()) as Promise; +} + +export function saveRecord(record: TimeRecord) { return saveRecords([record]); } + +export async function saveRecords(records: TimeRecord[]) { + const db = await database(); + for (let offset = 0; offset < records.length; offset += 200) { + const tx = db.transaction(["records", "outbox"], "readwrite"); + for (const record of records.slice(offset, offset + 200)) { + tx.objectStore("records").put(record); + tx.objectStore("outbox").put({ id: crypto.randomUUID(), createdAt: Date.now() + offset, record }); + } + await done(tx); + } +} + +export async function pendingOperations(): Promise { + const db = await database(); + const items = await result(db.transaction("outbox").objectStore("outbox").getAll()) as OutboxItem[]; + return items.sort((a, b) => a.createdAt - b.createdAt); +} + +export async function removeOperations(ids: string[]) { + const db = await database(); + const tx = db.transaction("outbox", "readwrite"); + for (const id of ids) tx.objectStore("outbox").delete(id); + await done(tx); +} + +export async function cursor(): Promise { + const db = await database(); + const value = await result(db.transaction("meta").objectStore("meta").get("cursor")) as { value?: number } | undefined; + return value?.value || 0; +} + +export async function applyRemote(records: TimeRecord[], nextCursor: number) { + const db = await database(); + const pending = await pendingOperations(); + const pendingIds = new Set(pending.map(item => item.record.id)); + const tx = db.transaction(["records", "meta"], "readwrite"); + for (const record of records) if (!pendingIds.has(record.id)) tx.objectStore("records").put(record); + tx.objectStore("meta").put({ key: "cursor", value: nextCursor }); + await done(tx); +} diff --git a/src/client/styles.css b/src/client/styles.css new file mode 100644 index 0000000..44f2c7f --- /dev/null +++ b/src/client/styles.css @@ -0,0 +1,229 @@ +:root { + color-scheme: dark; + --bg: #1e1e2e; + --panel: #181825; + --panel-soft: #313244; + --field-bg: #11111b; + --ink: #cdd6f4; + --muted: #bac2de; + --faint: #a6adc8; + --line: #6c7086; + --line-bright: #7f849c; + --accent: #cba6f7; + --accent-ink: #11111b; + --chart: #94e2d5; + --good: #a6e3a1; + --danger: #f38ba8; + --scrim: #11111bcc; + --shadow: #11111b99; + --font: "SFMono-Regular", "Cascadia Code", "IBM Plex Mono", "DejaVu Sans Mono", ui-monospace, monospace; +} + +:root[data-theme="latte"] { + color-scheme: light; + --bg: #e6e9ef; + --panel: #eff1f5; + --panel-soft: #dce0e8; + --field-bg: #e6e9ef; + --ink: #4c4f69; + --muted: #5c5f77; + --faint: #5c5f77; + --line: #7c7f93; + --line-bright: #6c6f85; + --accent: #8839ef; + --accent-ink: #eff1f5; + --chart: #179299; + --good: #40a02b; + --danger: #d20f39; + --scrim: #4c4f6999; + --shadow: #4c4f6933; +} + +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; color: var(--ink); background: var(--bg); font-family: var(--font); font-size: 14px; line-height: 1.5; } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .45; } +button, input, select { -webkit-tap-highlight-color: transparent; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +::selection { background: var(--accent); color: var(--accent-ink); } + +.shell { min-height: 100dvh; display: grid; grid-template-columns: 248px minmax(0, 1fr); } +.sidebar { border-right: 1px solid var(--line); min-height: 100dvh; padding: 32px 22px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100dvh; } +.brand { font-size: 22px; font-weight: 700; letter-spacing: -.08em; line-height: 1; display: flex; align-items: center; gap: 9px; } +.brand-mark { border: 1px solid var(--accent); padding: 3px 6px 4px; font-size: 15px; letter-spacing: -.1em; } +.brand-name { letter-spacing: -.08em; } +.sidebar .brand { margin-bottom: 42px; } +.nav { display: grid; gap: 6px; } +.nav-btn { width: 100%; text-align: left; padding: 12px 13px; color: var(--muted); background: none; border: 1px solid transparent; border-radius: 0; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; } +.nav-btn:hover { color: var(--ink); border-color: var(--line); } +.nav-btn.active { color: var(--accent); border-color: var(--accent); background: var(--panel); } +.nav-prefix { margin-right: 11px; color: var(--faint); } +.nav-btn.active .nav-prefix { color: var(--accent); } +.side-bottom { margin-top: auto; border-top: 1px solid var(--line); padding-top: 18px; font-size: 11px; color: var(--muted); } +.side-bottom .status-row { display: flex; align-items: center; gap: 9px; } +.status-dot { width: 6px; height: 6px; background: var(--good); display: inline-block; flex: 0 0 auto; } +.status-dot.offline, .status-dot.pending, .status-dot.signed-out { background: var(--faint); } + +.main { width: 100%; max-width: 1250px; margin: 0 auto; padding: 36px clamp(24px, 5vw, 76px) 90px; } +.topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: 24px; border-bottom: 1px solid var(--line); } +.topbar-date { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; } +.topbar-actions { display: flex; align-items: center; gap: 16px; } +.topbar-status { color: var(--muted); font-size: 11px; white-space: nowrap; } +.icon-btn { border: 1px solid var(--line); background: transparent; color: var(--ink); height: 35px; min-width: 35px; padding: 0 9px; } +.icon-btn:hover { border-color: var(--accent); } +.page { padding-top: 34px; } +.page-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 26px; } +.eyebrow { color: var(--muted); font-size: 11px; letter-spacing: .18em; text-transform: uppercase; margin: 0 0 10px; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(28px, 4vw, 44px); line-height: 1.1; font-weight: 500; letter-spacing: -.075em; } +h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .13em; margin: 0; } +.heading-meta { color: var(--muted); font-size: 11px; white-space: nowrap; padding-bottom: 5px; } +.grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 20px; } +.panel { border: 1px solid var(--line); background: var(--panel); min-width: 0; } +.panel-inner { padding: clamp(18px, 2.4vw, 31px); } +.panel-label { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; letter-spacing: .13em; text-transform: uppercase; gap: 12px; } +.panel-label .end { color: var(--faint); } +.dash { border-top: 1px dashed var(--line); margin: 24px 0; } +.timer-display { font-size: clamp(64px, 9vw, 118px); font-weight: 500; line-height: 1; letter-spacing: -.11em; font-variant-numeric: tabular-nums; margin: 28px 0 14px -7px; white-space: nowrap; } +.timer-display.idle { margin-top: 0; } +.timer-display .seconds { color: var(--muted); } +.timer-hint { color: var(--muted); font-size: 12px; min-height: 18px; } +.progress-track { height: 5px; border: 1px solid var(--line-bright); margin: 25px 0 30px; } +.progress-fill { height: 3px; background: var(--accent); width: 0; transition: width .4s linear; } +.field-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: .13em; text-transform: uppercase; margin-bottom: 9px; } +.field { width: 100%; border: 1px solid var(--line-bright); background: var(--field-bg); color: var(--ink); border-radius: 0; height: 47px; padding: 0 13px; outline: none; } +.field:focus { border-color: var(--accent); } +.field::placeholder { color: var(--faint); } +select.field { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 20px) 20px, calc(100% - 15px) 20px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 35px; } +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.duration-row { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 9px; } +.chip { border: 1px solid var(--line); background: transparent; color: var(--muted); min-height: 34px; padding: 5px 10px; font-size: 11px; } +.chip:hover { border-color: var(--accent); color: var(--accent); } +.chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); } +.primary-btn { width: 100%; border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); min-height: 54px; padding: 12px 20px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-top: 27px; } +.primary-btn:hover { background: transparent; color: var(--accent); } +.primary-btn.outline { background: transparent; color: var(--accent); } +.primary-btn.outline:hover { background: var(--accent); color: var(--accent-ink); } +.text-btn { border: 0; background: none; color: var(--muted); padding: 6px 0; font-size: 11px; text-decoration: underline; text-underline-offset: 4px; } +.text-btn:hover { color: var(--ink); } +.side-stat { font-size: clamp(36px, 4vw, 56px); letter-spacing: -.09em; font-weight: 500; line-height: 1; margin: 25px 0 10px; } +.muted { color: var(--muted); } +.mini-bars { display: flex; align-items: end; gap: 5px; height: 72px; margin-top: 23px; border-bottom: 1px solid var(--line); } +.mini-bar { flex: 1; min-width: 4px; background: var(--line-bright); height: 3px; } +.mini-bar.current { background: var(--accent); } +.section { margin-top: 31px; } +.section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 14px; border-bottom: 1px solid var(--line); } +.section-head .text-btn { text-decoration: none; } +.entry { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto auto; align-items: center; gap: 16px; min-height: 60px; border-bottom: 1px solid var(--line); color: var(--ink); } +.entry-time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.entry-tag { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-duration { font-variant-numeric: tabular-nums; white-space: nowrap; } +.entry-status { color: var(--faint); font-size: 10px; min-width: 14px; } +.entry-btn { border: 0; background: transparent; color: var(--faint); font-size: 12px; min-width: 36px; min-height: 40px; padding: 9px; } +.entry-btn:hover { color: var(--ink); } +.empty { color: var(--muted); font-size: 12px; padding: 27px 0; } + +.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 17px; } +.toolbar .field { width: auto; min-width: 180px; height: 40px; } +.toolbar .spacer { flex: 1; } +.small-btn { border: 1px solid var(--line-bright); background: transparent; color: var(--ink); min-height: 40px; padding: 8px 13px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } +.small-btn:hover { border-color: var(--accent); background: var(--panel-soft); } +.log-group { margin-bottom: 28px; } +.log-date { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .13em; display: flex; justify-content: space-between; padding: 11px 0; border-bottom: 1px dashed var(--line); } +.log-date span:last-child { color: var(--ink); } +.log-entry { grid-template-columns: 125px minmax(0, 1fr) 70px 28px; } +.log-entry .entry-time { font-size: 11px; } +.log-entry .entry-tag small { color: var(--faint); display: block; font-size: 10px; } +.log-entry .entry-tag { line-height: 1.25; } +.log-entry .entry-duration { text-align: right; } + +.range-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.segmented { display: inline-flex; border: 1px solid var(--line-bright); } +.segment { border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--muted); padding: 9px 11px; font-size: 11px; text-transform: uppercase; } +.segment:last-child { border-right: 0; } +.segment.active { background: var(--accent); color: var(--accent-ink); } +.range-label { color: var(--muted); font-size: 11px; min-width: 115px; text-align: center; } +.insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.insight-big { font-size: clamp(36px, 5vw, 60px); letter-spacing: -.09em; line-height: 1; margin: 24px 0 10px; } +.trend-chart { height: 220px; display: flex; gap: 8px; align-items: stretch; margin-top: 28px; } +.trend-col { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 7px; } +.trend-value { color: var(--muted); font-size: 9px; white-space: nowrap; } +.trend-track { height: 170px; width: 100%; display: flex; align-items: end; border-bottom: 1px solid var(--line-bright); } +.trend-fill { width: 100%; min-height: 2px; background: var(--muted); } +.trend-col.current .trend-fill { background: var(--accent); } +.trend-label { color: var(--faint); font-size: 10px; overflow: hidden; white-space: nowrap; max-width: 100%; } +.tag-row { margin-top: 19px; } +.tag-row-head { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; margin-bottom: 6px; } +.tag-row-head span:last-child { color: var(--muted); white-space: nowrap; } +.tag-track { height: 7px; background: var(--line); } +.tag-fill { height: 7px; background: var(--chart); } +.insight-note { color: var(--faint); font-size: 10px; margin: 24px 0 0; } +.settings-row { border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 22px 0; } +.settings-row h3 { font-size: 12px; letter-spacing: .04em; margin: 0 0 4px; } +.settings-row p { color: var(--muted); font-size: 11px; margin: 0; max-width: 520px; } +.settings-row .small-btn { flex-shrink: 0; } +.theme-toggle { display: inline-flex; border: 1px solid var(--line-bright); flex-shrink: 0; } +.theme-option { min-width: 88px; min-height: 42px; border: 0; background: transparent; color: var(--ink); padding: 8px 14px; font-size: 11px; font-weight: 700; letter-spacing: .08em; } +.theme-option + .theme-option { border-left: 1px solid var(--line-bright); } +.theme-option:hover:not(.active) { background: var(--panel-soft); } +.theme-option.active { background: var(--accent); color: var(--accent-ink); } +.hidden-input { display: none; } +.notice { border: 1px solid var(--line-bright); padding: 12px 14px; color: var(--muted); font-size: 11px; margin: 16px 0; } +.notice.error { border-color: var(--danger); color: var(--danger); } +.notice.success { border-color: var(--good); color: var(--ink); } + +.modal-backdrop { position: fixed; inset: 0; z-index: 20; background: var(--scrim); display: flex; align-items: center; justify-content: center; padding: 18px; } +.modal { width: min(100%, 470px); max-height: min(90dvh, 720px); overflow: auto; border: 1px solid var(--line-bright); background: var(--panel); padding: 25px; box-shadow: 8px 8px 0 var(--shadow); } +.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; } +.modal .field { margin-bottom: 16px; } +.modal-actions { display: flex; gap: 9px; margin-top: 12px; } +.modal-actions .small-btn { flex: 1; } +.modal-actions .danger { color: var(--danger); border-color: var(--danger); } +.toast { position: fixed; right: 25px; bottom: 25px; z-index: 30; background: var(--accent); color: var(--accent-ink); padding: 13px 17px; font-size: 12px; max-width: min(380px, calc(100vw - 36px)); box-shadow: 5px 5px 0 var(--shadow); } +.login { min-height: 100dvh; display: flex; justify-content: center; align-items: center; padding: 24px; } +.login-card { border: 1px solid var(--line-bright); background: var(--panel); width: min(100%, 430px); padding: clamp(24px, 5vw, 42px); } +.login-card h1 { margin: 35px 0 17px; } +.login-card p { color: var(--muted); font-size: 12px; } +.login-card .primary-btn { display: block; text-decoration: none; text-align: center; } +.mobile-nav { display: none; } + +@media (max-width: 920px) { + .shell { grid-template-columns: 1fr; } + .sidebar { display: none; } + .main { padding: calc(28px + env(safe-area-inset-top, 0px)) calc(26px + env(safe-area-inset-right, 0px)) calc(104px + env(safe-area-inset-bottom, 0px)) calc(26px + env(safe-area-inset-left, 0px)); } + .topbar .icon-btn { min-width: 48px; height: 48px; } + .grid, .insight-grid { grid-template-columns: 1fr; } + .mobile-nav { display: grid; grid-template-columns: repeat(3, 1fr); position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; background: var(--bg); border-top: 1px solid var(--line-bright); padding: 7px 8px calc(7px + env(safe-area-inset-bottom)); } + .mobile-nav .nav-btn { text-align: center; padding: 10px 3px; min-height: 43px; font-size: 10px; } + .mobile-nav .nav-prefix { display: none; } + .toast { bottom: calc(75px + env(safe-area-inset-bottom)); right: 18px; } +} +@media (max-width: 560px) { + .main { padding: calc(24px + env(safe-area-inset-top, 0px)) calc(20px + env(safe-area-inset-right, 0px)) calc(100px + env(safe-area-inset-bottom, 0px)) calc(20px + env(safe-area-inset-left, 0px)); } + .topbar { padding-bottom: 17px; } + .topbar-date { font-size: 10px; } + .topbar-status { display: none; } + .page { padding-top: 27px; } + .page-heading { margin-bottom: 22px; } + .heading-meta { display: none; } + .panel-inner { padding: 19px; } + .timer-display { font-size: clamp(58px, 14vw, 84px); margin-left: -4px; } + .field-grid { grid-template-columns: 1fr; gap: 17px; } + .entry { grid-template-columns: 102px minmax(0, 1fr) auto auto; gap: 7px; font-size: 11px; } + .entry-time { font-size: 10px; } + .log-entry { grid-template-columns: 102px minmax(0, 1fr) 50px 36px; } + .settings-row { align-items: start; flex-direction: column; gap: 10px; } + .theme-toggle { width: 100%; } + .theme-option { flex: 1; } + .trend-chart { gap: 4px; } + .trend-value { font-size: 8px; } + .trend-label { font-size: 9px; } + .range-controls { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; gap: 10px; } + .range-controls .segmented { grid-column: 1 / -1; display: flex; } + .range-controls .segment { flex: 1; } + .range-controls .spacer { display: none; } + .range-controls .range-label { min-width: 0; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } diff --git a/src/client/sw.js b/src/client/sw.js new file mode 100644 index 0000000..65efdbb --- /dev/null +++ b/src/client/sw.js @@ -0,0 +1,42 @@ +const CACHE = "timelog-__BUILD_ID__"; +const ASSETS = ["/", "/index.html", "/styles.css?v=__BUILD_ID__", "/assets/app.js?v=__BUILD_ID__", "/manifest.webmanifest", "/icon.svg", "/icon-192.png", "/icon-512.png", "/icon-180.png"]; + +self.addEventListener("install", event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", event => { + event.waitUntil((async () => { + for (const name of await caches.keys()) if (name !== CACHE) await caches.delete(name); + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", event => { + const request = event.request; + const url = new URL(request.url); + if (url.origin !== self.location.origin || url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return; + if (request.mode === "navigate") { + event.respondWith(fetch(request).catch(async () => (await caches.match("/index.html")) || Response.error())); + return; + } + event.respondWith(caches.match(request).then(cached => cached || fetch(request))); +}); + +self.addEventListener("push", event => { + let data = { title: "Session complete", body: "Your time is up.", tag: "timelog", url: "/" }; + try { data = { ...data, ...event.data.json() }; } catch {} + event.waitUntil(self.registration.showNotification(data.title, { + body: data.body, tag: data.tag, icon: "/icon-192.png", badge: "/icon-192.png", data: { url: data.url }, + })); +}); + +self.addEventListener("notificationclick", event => { + event.notification.close(); + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const existing = clients.find(client => new URL(client.url).pathname === "/"); + if (existing) return existing.focus(); + return self.clients.openWindow(event.notification.data?.url || "/"); + })()); +}); diff --git a/src/client/sync.ts b/src/client/sync.ts new file mode 100644 index 0000000..b53e069 --- /dev/null +++ b/src/client/sync.ts @@ -0,0 +1,58 @@ +import type { TimeRecord } from "../shared/record.ts"; +import { allRecords, applyRemote, cursor, pendingOperations, removeOperations } from "./store.ts"; + +export type SyncStatus = "offline" | "syncing" | "synced" | "pending" | "signed-out"; +let current: Promise | null = null; +let rerunRequested = false; + +class SignedOut extends Error {} + +async function api(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (response.status === 401) throw new SignedOut(); + if (!response.ok) throw new Error(`Sync failed (${response.status})`); + return response.json() as Promise; +} + +export function syncNow(onChange: (status: SyncStatus, records?: TimeRecord[]) => void): Promise { + if (current) { + rerunRequested = true; + return current; + } + current = (async () => { + if (!navigator.onLine) { onChange("offline"); return; } + onChange("syncing"); + try { + let pending = await pendingOperations(); + while (pending.length) { + const batch = pending.slice(0, 200); + const body = await api<{ accepted: string[] }>("/api/operations", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operations: batch.map(({ id, record }) => ({ id, record })) }), + }); + await removeOperations(body.accepted); + pending = await pendingOperations(); + } + let since = await cursor(); + for (;;) { + const body = await api<{ records: TimeRecord[]; cursor: number; hasMore: boolean }>( + `/api/state?since=${since}`, { cache: "no-store" }); + await applyRemote(body.records, body.cursor); + since = body.cursor; + if (!body.hasMore) break; + } + onChange("synced", await allRecords()); + } catch (error) { + if (error instanceof SignedOut) { onChange("signed-out"); return; } + console.error(error); + onChange("pending"); + } + })().finally(() => { + current = null; + if (rerunRequested) { + rerunRequested = false; + void syncNow(onChange); + } + }); + return current; +} diff --git a/src/client/title.ts b/src/client/title.ts new file mode 100644 index 0000000..c2001fd --- /dev/null +++ b/src/client/title.ts @@ -0,0 +1,16 @@ +import { dueMs, type TimeRecord } from "../shared/record.ts"; + +const APP_NAME = "Timelog"; + +export function pad(value: number) { return String(value).padStart(2, "0"); } + +export function countdown(ms: number) { + const total = Math.max(0, Math.ceil(ms / 1000)); + return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`; +} + +/** The browser tab title: the remaining time and tag while a session runs. */ +export function pageTitle(active: Pick | null, now = Date.now()) { + if (!active) return APP_NAME; + return `${countdown(dueMs(active) - now)} · ${active.tag} — ${APP_NAME}`; +} diff --git a/src/shared/record.ts b/src/shared/record.ts new file mode 100644 index 0000000..9110d49 --- /dev/null +++ b/src/shared/record.ts @@ -0,0 +1,25 @@ +export type TimeRecord = { + id: string; + startMs: number; + endMs: number | null; + targetSeconds: number | null; + tag: string; + note: string; + treeType: string; + isSuccess: boolean | null; + source: "timer" | "forest" | "manual"; + rawStart: string | null; + rawEnd: string | null; + deleted: boolean; + revision?: number; +}; + +/** The time at which a timer session reaches its target duration. */ +export function dueMs(record: Pick): number { + return record.startMs + (record.targetSeconds || 0) * 1000; +} + +/** The end of a record; a running session ends now, but not after it is due. */ +export function recordEnd(record: TimeRecord, now = Date.now()): number { + return record.endMs ?? Math.min(now, dueMs(record)); +} diff --git a/src/shared/tag.ts b/src/shared/tag.ts new file mode 100644 index 0000000..6d8ea65 --- /dev/null +++ b/src/shared/tag.ts @@ -0,0 +1,10 @@ +/** Keep ordinary words in title case while preserving intentional mixed-case names and short acronyms. */ +export function normalizeTag(value: string): string { + return value.trim().replace(/\s+/g, " ").replace(/\p{L}[\p{L}\p{M}]*/gu, word => { + const lower = word.toLocaleLowerCase(); + const upper = word.toLocaleUpperCase(); + if (word !== lower && word !== upper) return word; + if (word === upper && word.length <= 3) return word; + return lower[0]!.toLocaleUpperCase() + lower.slice(1); + }); +} diff --git a/src/web-server.ts b/src/web-server.ts new file mode 100644 index 0000000..3088592 --- /dev/null +++ b/src/web-server.ts @@ -0,0 +1,33 @@ +const port = Number(process.env.WEB_PORT || 3000); +const apiOrigin = process.env.API_ORIGIN || "http://localhost:3001"; + +function staticResponse(path: string) { + const file = Bun.file(`dist${path}`); + return file.exists().then(exists => exists + ? new Response(file, { headers: { "Cache-Control": path === "/sw.js" || path === "/index.html" ? "no-cache" : "public, max-age=3600" } }) + : null); +} + +Bun.serve({ + port, + hostname: process.env.WEB_HOST || "127.0.0.1", + async fetch(request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) { + const upstream = new URL(url.pathname + url.search, apiOrigin); + const headers = new Headers(request.headers); + headers.delete("host"); + headers.set("X-Forwarded-Host", url.host); + headers.set("X-Forwarded-Proto", url.protocol.slice(0, -1)); + return fetch(upstream, { method: request.method, headers, body: request.body, redirect: "manual" }); + } + const path = decodeURIComponent(url.pathname); + if (path.includes("..") || path.includes("\\")) return new Response("Not found", { status: 404 }); + const requested = path === "/" ? "/index.html" : path; + const response = await staticResponse(requested); + if (response) return response; + if (!requested.includes(".")) return (await staticResponse("/index.html"))!; + return new Response("Not found", { status: 404 }); + }, +}); +console.log(`Timelog web listening on ${port}`); diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..dd13e64 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TimeRecord } from "../src/shared/record.ts"; + +let processHandle: ReturnType; +let folder: string; +const port = 33000 + Math.floor(Math.random() * 10000); +const base = `http://127.0.0.1:${port}`; + +beforeAll(async () => { + folder = await mkdtemp(join(tmpdir(), "timelog-api-test-")); + processHandle = Bun.spawn([process.execPath, "src/api/server.ts"], { + cwd: process.cwd(), + env: { ...process.env, API_PORT: String(port), DB_PATH: join(folder, "test.sqlite"), + PUBLIC_ORIGIN: "http://localhost:3000", DEV_AUTH: "1" }, + stdout: "ignore", stderr: "pipe", + }); + for (let attempt = 0; attempt < 40; attempt++) { + try { if ((await fetch(`${base}/api/health`)).ok) return; } catch {} + await Bun.sleep(50); + } + throw new Error("API did not start"); +}); + +afterAll(async () => { + processHandle?.kill(); + await processHandle?.exited; + if (folder) await rm(folder, { recursive: true, force: true }); +}); + +test("sync is idempotent and completes a due session at its target", async () => { + const startMs = Date.now() - 10_000; + const record: TimeRecord = { + id: "session_integration_123", startMs, endMs: null, targetSeconds: 5, + tag: "job hunting", note: "", treeType: "", isSuccess: null, source: "timer", + rawStart: null, rawEnd: null, deleted: false, + }; + const request = { operations: [{ id: "op_integration_123", record }] }; + const options = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request) }; + expect((await fetch(`${base}/api/operations`, options)).status).toBe(200); + expect((await fetch(`${base}/api/operations`, options)).status).toBe(200); + const state = await (await fetch(`${base}/api/state?since=0`)).json() as { + records: TimeRecord[]; cursor: number; + }; + expect(state.records).toHaveLength(1); + expect(state.records[0]!.endMs).toBe(startMs + 5000); + expect(state.records[0]!.isSuccess).toBe(true); + expect(state.records[0]!.tag).toBe("Job Hunting"); + expect(state.cursor).toBe(2); +}); diff --git a/tests/csv.test.ts b/tests/csv.test.ts new file mode 100644 index 0000000..4e3c0e4 --- /dev/null +++ b/tests/csv.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { exportForestCsv, importForestCsv, parseCsv } from "../src/client/csv.ts"; +import { overlapMs, rangeWindow, totals } from "../src/client/analytics.ts"; +import { normalizeTag } from "../src/shared/tag.ts"; + +describe("Forest CSV", () => { + test("parses quotes, commas, and newlines", () => { + expect(parseCsv('a,b\r\n"one,two","line 1\nline 2"\r\n')).toEqual([ + ["a", "b"], ["one,two", "line 1\nline 2"], + ]); + }); + + test("imports the supplied Forest export and round trips its data", async () => { + const source = [...new Bun.Glob("*.csv").scanSync(".")][0]; + expect(source).toBeDefined(); + const records = await importForestCsv(await Bun.file(source!).text()); + expect(records).toHaveLength(2709); + expect(new Set(records.map(record => record.id)).size).toBe(records.length); + expect(records.filter(record => record.isSuccess === false)).toHaveLength(28); + expect(records.filter(record => record.tag === "Job Hunting")).toHaveLength(3); + expect(records.filter(record => record.tag === "Music")).toHaveLength(12); + expect(records.filter(record => record.tag === "Paperwork")).toHaveLength(2); + const output = parseCsv(exportForestCsv(records)); + expect(output).toHaveLength(2710); + expect(output[0]).toEqual(["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"]); + expect(output[1]).toEqual([ + "2021-04-28T20:38:16.930-0400", "2021-04-28T21:03:16.930-0400", "Study", "", "Cedar", "True", + ]); + }); +}); + +test("tag capitalization keeps mixed-case names and short acronyms", () => { + expect(normalizeTag(" job hunting ")).toBe("Job Hunting"); + expect(normalizeTag("iOS AI study")).toBe("iOS AI Study"); +}); + +describe("insights", () => { + test("splits a session crossing midnight and counts early stops", () => { + const start = +new Date(2026, 8, 20, 23, 30); + const end = +new Date(2026, 8, 21, 0, 30); + const record = { + id: "session_test", startMs: start, endMs: end, targetSeconds: 7200, tag: "Study", note: "", + treeType: "", isSuccess: false, source: "timer" as const, rawStart: null, rawEnd: null, deleted: false, + }; + const first = rangeWindow("day", 0, new Date(2026, 8, 20)); + const second = rangeWindow("day", 0, new Date(2026, 8, 21)); + expect(overlapMs(record, first.start, first.end)).toBe(30 * 60 * 1000); + expect(totals([record], second).totalMs).toBe(30 * 60 * 1000); + expect(totals([record], first).tags).toEqual([["Study", 30 * 60 * 1000]]); + }); +}); diff --git a/tests/record.test.ts b/tests/record.test.ts new file mode 100644 index 0000000..491b160 --- /dev/null +++ b/tests/record.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { dueMs, recordEnd, type TimeRecord } from "../src/shared/record.ts"; + +const base: TimeRecord = { + id: "session_record_test", startMs: 1_000_000, endMs: null, targetSeconds: 60, tag: "Study", note: "", + treeType: "", isSuccess: null, source: "timer", rawStart: null, rawEnd: null, deleted: false, +}; + +test("a running session ends now until it is due", () => { + expect(dueMs(base)).toBe(1_060_000); + expect(recordEnd(base, 1_030_000)).toBe(1_030_000); + expect(recordEnd(base, 2_000_000)).toBe(1_060_000); +}); + +test("a finished record keeps its own end", () => { + expect(recordEnd({ ...base, endMs: 1_010_000 }, 2_000_000)).toBe(1_010_000); +}); diff --git a/tests/title.test.ts b/tests/title.test.ts new file mode 100644 index 0000000..8fc59cc --- /dev/null +++ b/tests/title.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { countdown, pageTitle } from "../src/client/title.ts"; + +const start = +new Date(2026, 10, 5, 9, 0, 0); +const session = { startMs: start, targetSeconds: 25 * 60, tag: "Study" }; + +describe("browser tab title", () => { + test("is the app name when no session runs", () => { + expect(pageTitle(null, start)).toBe("Timelog"); + }); + + test("shows the remaining time and tag while a session runs", () => { + expect(pageTitle(session, start)).toBe("25:00 · Study — Timelog"); + expect(pageTitle(session, start + 61_500)).toBe("23:59 · Study — Timelog"); + }); + + test("stops at 00:00 when the session is due", () => { + expect(pageTitle(session, start + 26 * 60_000)).toBe("00:00 · Study — Timelog"); + }); + + test("countdown rounds partial seconds up", () => { + expect(countdown(1)).toBe("00:01"); + expect(countdown(120 * 60_000)).toBe("120:00"); + expect(countdown(-5)).toBe("00:00"); + }); +}); + +test("redundant UI text is gone", async () => { + const source = (await Bun.file("src/client/main.ts").text()).toLowerCase(); + for (const text of [ + "a quiet record of time", "local first", "forest csv / v1", + "one session at a time", "make time count", "actual logged time", + ]) expect(source).not.toContain(text); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..872768a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "WebWorker"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true + }, + "include": ["src", "scripts", "tests"] +}