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 <[email protected]>
This commit is contained in:
38 files changed
+2069
No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
*.sqlite*
|
||||
*.csv
|
||||
@@ -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:[email protected]
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
.env
|
||||
.DS_Store
|
||||
*.csv
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 <http://localhost:3000>. 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.
|
||||
@@ -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/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],
|
||||
|
||||
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||
|
||||
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"asn1.js": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
|
||||
|
||||
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"http_ece": ["[email protected]", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"minimalistic-assert": ["[email protected]", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"safe-buffer": ["[email protected]", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"web-push": ["[email protected]", "", { "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=="],
|
||||
}
|
||||
}
|
||||
+36
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/");
|
||||
@@ -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}`);
|
||||
+125
@@ -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<?").run(Date.now());
|
||||
const state = randomString();
|
||||
const verifier = randomString();
|
||||
const challenge = await digest(verifier);
|
||||
db.query("INSERT INTO oauth_states(state,verifier,expires_ms) VALUES (?,?,?)")
|
||||
.run(state, verifier, Date.now() + 10 * 60 * 1000);
|
||||
const url = new URL("https://github.com/login/oauth/authorize");
|
||||
url.searchParams.set("client_id", process.env.GITHUB_CLIENT_ID!);
|
||||
url.searchParams.set("redirect_uri", `${ORIGIN}/auth/github/callback`);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("allow_signup", "false");
|
||||
return new Response(null, { status: 302, headers: { Location: url.toString(), "Set-Cookie": stateCookie(state) } });
|
||||
}
|
||||
|
||||
export async function finishGithubLogin(request: Request) {
|
||||
if (!hasGithubConfig()) return new Response("GitHub sign-in is not configured", { status: 503 });
|
||||
const url = new URL(request.url);
|
||||
const state = url.searchParams.get("state");
|
||||
const code = url.searchParams.get("code");
|
||||
if (!state || !code) return new Response("Missing GitHub authorization", { status: 400 });
|
||||
if (cookie(request, "timelog_oauth_state") !== state) return new Response("Sign-in request did not match this browser", { status: 400 });
|
||||
const row = db.query<{ verifier: string; expires_ms: number }, [string]>(
|
||||
"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) } });
|
||||
}
|
||||
+171
@@ -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<TimeRecord>;
|
||||
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<Operation>;
|
||||
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<DbRecord, [number, number]>(
|
||||
"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<DbRecord, [number]>(
|
||||
"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();
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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<string, number>();
|
||||
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]),
|
||||
};
|
||||
}
|
||||
@@ -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<TimeRecord[]> {
|
||||
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";
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="72" fill="#1e1e2e"/>
|
||||
<path d="M96 118h320v276H96z" fill="none" stroke="#cba6f7" stroke-width="18"/>
|
||||
<path d="M132 162h84m-84 38h52m-52 38h66" stroke="#a6adc8" stroke-width="14"/>
|
||||
<path d="M267 176h93m-46 0v160" stroke="#cdd6f4" stroke-width="21" stroke-linecap="square"/>
|
||||
<path d="M267 336h94" stroke="#cdd6f4" stroke-width="21" stroke-linecap="square"/>
|
||||
<path d="M132 350h74" stroke="#a6adc8" stroke-width="14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1e1e2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="A quiet place to log your time." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||||
<script>
|
||||
try {
|
||||
if (localStorage.getItem("timelog-theme") === "latte") {
|
||||
document.documentElement.dataset.theme = "latte";
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute("content", "#e6e9ef");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=__BUILD_ID__" />
|
||||
<title>Timelog</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/assets/app.js?v=__BUILD_ID__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 `<button class="nav-btn ${view === target ? "active" : ""}" data-action="nav" data-view="${target}" type="button"><span class="nav-prefix">${index}</span>${label}</button>`;
|
||||
}
|
||||
|
||||
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 `<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
|
||||
<nav class="nav" aria-label="Main navigation">
|
||||
${navButtons()}
|
||||
</nav>
|
||||
<div class="side-bottom"><div class="status-row"><span class="status-dot ${syncStatus}"></span>${statusText()}</div></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar"><span class="topbar-date">${h(today)}</span><div class="topbar-actions"><span class="topbar-status">${statusText()}</span><button class="icon-btn" title="Settings" aria-label="Settings" data-action="nav" data-view="settings" type="button">⚙</button></div></header>
|
||||
${content}
|
||||
</main>
|
||||
<nav class="mobile-nav" aria-label="Mobile navigation">${navButtons()}</nav>
|
||||
</div>${editingId ? editorModal() : ""}${message ? `<div class="toast" role="status">${h(message.text)}</div>` : ""}`;
|
||||
}
|
||||
|
||||
function loginPage() {
|
||||
const offline = !navigator.onLine;
|
||||
return `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
|
||||
<h1>Time, plainly.</h1><p>One timer. One log. A clear view of where the hours went.</p>
|
||||
${offline ? `<div class="notice">You're offline. Sign in once while connected to use this device offline.</div>` : ""}
|
||||
${me && !me.githubConfigured ? `<div class="notice error">GitHub sign-in has not been configured on the server yet.</div>` : ""}
|
||||
<a class="primary-btn" href="/auth/github">> SIGN IN WITH GITHUB</a><p style="margin-top:22px">Private to your account. No repository access.</p>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function pageHeading(title: string, meta = "", eyebrow = "") {
|
||||
return `<div class="page-heading"><div>${eyebrow ? `<p class="eyebrow">${eyebrow}</p>` : ""}<h1>${title}</h1></div>${meta ? `<span class="heading-meta">${meta}</span>` : ""}</div>`;
|
||||
}
|
||||
|
||||
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 => `<option value="${h(tag)}" ${tag === selectedTag ? "selected" : ""}>${h(tag)}</option>`).join("");
|
||||
const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6);
|
||||
const weekMax = Math.max(...week.trend, 1);
|
||||
return `<section class="page">
|
||||
<div class="grid">
|
||||
<div class="panel"><div class="panel-inner">
|
||||
${active ? `<div class="panel-label"><span>● SESSION IN PROGRESS</span><span class="end">${h(active.tag.toUpperCase())}</span></div>` : ""}
|
||||
<div class="timer-display ${active ? "" : "idle"}" id="timer-display">${active ? countdown(dueMs(active) - Date.now()) : `${pad(selectedDuration)}<span class="seconds">:00</span>`}</div>
|
||||
${active ? `<div class="timer-hint" id="timer-hint">Started ${clock(active.startMs)} · Ends ${clock(dueMs(active))}</div>` : ""}
|
||||
<div class="progress-track"><div class="progress-fill" id="progress-fill" style="width:${active ? progress(active) : 0}%"></div></div>
|
||||
${active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center">${h(active.tag)}</div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center">${duration(active.targetSeconds! * 1000)}</div></div></div>
|
||||
<button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY & LOG TIME</button>` : `
|
||||
<div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag">${tagOptions}<option value="__new__">+ New tag...</option></select></div>
|
||||
<div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value="${selectedDuration}" /></div></div>
|
||||
<div class="duration-row">${[15, 25, 30, 45, 60, 120].map(n => `<button class="chip ${n === selectedDuration ? "active" : ""}" data-action="duration" data-minutes="${n}" type="button">${n}m</button>`).join("")}</div>
|
||||
<button class="primary-btn" data-action="start" type="button">> START SESSION</button>`}
|
||||
</div></div>
|
||||
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end">${h(shortDate(todayWindow.start).toUpperCase())}</span></div>
|
||||
<div class="side-stat" id="today-total">${duration(today.totalMs)}</div><div class="muted" style="font-size:12px">${completedToday} ${completedToday === 1 ? "session" : "sessions"} logged${active ? " · 1 running" : ""}</div>
|
||||
<div class="dash"></div><div class="panel-label"><span>THIS WEEK</span><span class="end">${duration(week.totalMs)}</span></div>
|
||||
<div class="mini-bars">${week.trend.map((value, i) => `<div class="mini-bar ${i === ((new Date().getDay() + 6) % 7) ? "current" : ""}" title="${duration(value)}" style="height:${Math.max(3, value / weekMax * 100)}%"></div>`).join("")}</div>
|
||||
<div class="muted" style="display:flex;justify-content:space-between;font-size:10px;margin-top:8px"><span>MON</span><span>SUN</span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<section class="section"><div class="section-head"><h2>Today's log</h2><button class="text-btn" data-action="nav" data-view="log" type="button">VIEW ALL →</button></div>
|
||||
${latest.length ? latest.map(record => entryRow(record)).join("") : `<div class="empty">No time logged today. Start when you're ready.</div>`}
|
||||
</section>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function entryRow(record: TimeRecord, editable = false) {
|
||||
const end = recordEnd(record);
|
||||
return `<div class="entry ${editable ? "log-entry" : ""}"><span class="entry-time">${clock(record.startMs)}–${record.endMs === null ? "NOW" : clock(end)}</span>
|
||||
<span class="entry-tag">${h(record.tag)}${editable && record.note ? `<small>${h(record.note)}</small>` : ""}</span>
|
||||
<span class="entry-duration">${duration(end - record.startMs)}</span>
|
||||
${editable ? `<button class="entry-btn" data-action="edit" data-id="${h(record.id)}" aria-label="Edit ${h(record.tag)} session" type="button">✎</button>` : `<span class="entry-status" title="${record.isSuccess === false ? "Stopped early or unsuccessful" : "Complete"}">${record.isSuccess === false ? "○" : "·"}</span>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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<string, TimeRecord[]>();
|
||||
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 => `<option value="${h(tag)}" ${tag === logTag ? "selected" : ""}>${h(tag)}</option>`).join("");
|
||||
return `<section class="page">${pageHeading("Your time, in order.", `${filtered.length} ENTRIES`)}
|
||||
<div class="toolbar"><button class="small-btn" data-action="prev-log" type="button" aria-label="Previous month">←</button>
|
||||
<span class="range-label">${h(month.toLocaleDateString(undefined, { month: "long", year: "numeric" }).toUpperCase())}</span>
|
||||
<button class="small-btn" data-action="next-log" type="button" aria-label="Next month">→</button>
|
||||
<select class="field" id="log-tag" aria-label="Filter by tag"><option value="all">ALL TAGS</option>${tagOptions}</select>
|
||||
<span class="spacer"></span><button class="small-btn" data-action="add" type="button">+ ADD ENTRY</button></div>
|
||||
${groups.size ? [...groups].map(([label, entries]) => `<div class="log-group"><div class="log-date"><span>${h(label.toUpperCase())}</span><span>${duration(entries.reduce((sum, record) => sum + Math.max(0, recordEnd(record) - record.startMs), 0))}</span></div>${entries.map(record => entryRow(record, true)).join("")}</div>`).join("") : `<div class="empty">No entries for this month${logTag !== "all" ? " and tag" : ""}.</div>`}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 `<section class="page">${pageHeading("Where time goes.")}
|
||||
<div class="toolbar range-controls"><div class="segmented" role="group" aria-label="Insight range">${(["day", "week", "month", "year"] as RangeKind[]).map(kind => `<button class="segment ${insightKind === kind ? "active" : ""}" data-action="range" data-range="${kind}" type="button">${kind}</button>`).join("")}</div>
|
||||
<span class="spacer"></span><button class="small-btn" data-action="prev-insight" type="button" aria-label="Previous period">←</button>
|
||||
<span class="range-label">${h(range.label.toUpperCase())}</span><button class="small-btn" data-action="next-insight" type="button" aria-label="Next period">→</button></div>
|
||||
<div class="insight-grid"><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TIME TREND</span><span class="end">${h(insightKind.toUpperCase())}</span></div>
|
||||
<div class="insight-big">${duration(stats.totalMs)}</div><div class="muted" style="font-size:11px">${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period</div>
|
||||
<div class="trend-chart" role="img" aria-label="Logged time trend for ${h(range.label)}">${range.bins.map((bin, index) => {
|
||||
const value = stats.trend[index]!;
|
||||
const isCurrent = Date.now() >= bin.start && Date.now() < bin.end;
|
||||
return `<div class="trend-col ${isCurrent ? "current" : ""}" title="${h(bin.label)}: ${duration(value)}"><span class="trend-value">${value ? duration(value) : ""}</span><div class="trend-track"><div class="trend-fill" style="height:${Math.max(2, value / max * 100)}%"></div></div><span class="trend-label">${h(bin.label)}</span></div>`;
|
||||
}).join("")}</div>
|
||||
</div></div><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// BY TAG</span><span class="end">${stats.tags.length} CATEGORIES</span></div>
|
||||
${stats.tags.length ? stats.tags.map(([tag, value]) => `<div class="tag-row"><div class="tag-row-head"><span>${h(tag)}</span><span>${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%</span></div><div class="tag-track"><div class="tag-fill" style="width:${Math.max(1, value / maxTag * 100)}%"></div></div></div>`).join("") : `<div class="empty">No time logged in this period.</div>`}
|
||||
<p class="insight-note">Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.</p>
|
||||
</div></div></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function settingsPage() {
|
||||
const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window;
|
||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||
return `<section class="page">${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
|
||||
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// DATA & DEVICE</span><span class="end">PRIVATE</span></div>
|
||||
<div class="settings-row"><div><h3>Appearance</h3><p>Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme"><button class="theme-option ${theme === "latte" ? "active" : ""}" data-action="theme" data-theme="latte" aria-pressed="${theme === "latte"}" type="button">LATTE</button><button class="theme-option ${theme === "mocha" ? "active" : ""}" data-action="theme" data-theme="mocha" aria-pressed="${theme === "mocha"}" type="button">MOCHA</button></div></div>
|
||||
<div class="settings-row"><div><h3>Forest CSV import</h3><p>Bring in your existing sessions. Re-importing the same file skips duplicates.</p></div><button class="small-btn" data-action="import" type="button">IMPORT CSV</button><input class="hidden-input" id="csv-file" type="file" accept=".csv,text/csv" /></div>
|
||||
<div class="settings-row"><div><h3>Forest CSV export</h3><p>Download your completed sessions with Forest's six-column layout.</p></div><button class="small-btn" data-action="export" type="button">EXPORT CSV</button></div>
|
||||
<div class="settings-row"><div><h3>Completion notifications</h3><p>${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."}</p></div><button class="small-btn" data-action="notify" type="button" ${!canNotify || !pushRegistration || permission === "denied" ? "disabled" : ""}>${permission === "granted" ? "RECONNECT" : "ENABLE"}</button></div>
|
||||
<div class="settings-row"><div><h3>Sync</h3><p>${statusText()}. Sessions remain here until the server can receive them.</p></div><button class="small-btn" data-action="sync" type="button">SYNC NOW</button></div>
|
||||
<div class="settings-row"><div><h3>Account</h3><p>${me?.dev ? "Local development mode" : "Signed in with your GitHub account"}</p></div><button class="small-btn" data-action="logout" type="button">SIGN OUT</button></div>
|
||||
</div></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 `<div class="modal-backdrop"><div class="modal" role="dialog" aria-modal="true" aria-label="${existing ? "Edit" : "Add"} log entry">
|
||||
<div class="modal-head"><h2>${existing ? "EDIT ENTRY" : "ADD ENTRY"}</h2><button class="icon-btn" data-action="close-modal" aria-label="Close" type="button">×</button></div>
|
||||
<label class="field-label" for="edit-tag">TAG</label><input class="field" id="edit-tag" maxlength="100" value="${h(existing?.tag || selectedTag)}" list="known-tags" />
|
||||
<datalist id="known-tags">${tags().map(tag => `<option value="${h(tag)}"></option>`).join("")}</datalist>
|
||||
<label class="field-label" for="edit-start">START</label><input class="field" id="edit-start" type="datetime-local" step="1" value="${datetimeLocal(start)}" />
|
||||
<label class="field-label" for="edit-end">END</label><input class="field" id="edit-end" type="datetime-local" step="1" value="${datetimeLocal(end)}" />
|
||||
<label class="field-label" for="edit-note">NOTE / OPTIONAL</label><input class="field" id="edit-note" maxlength="2000" value="${h(existing?.note || "")}" />
|
||||
<div class="modal-actions"><button class="small-btn" data-action="save-edit" type="button">SAVE ENTRY</button>${existing ? `<button class="small-btn danger" data-action="delete-edit" type="button">DELETE</button>` : ""}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!me) {
|
||||
app.innerHTML = `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div><p style="margin-top:32px">Opening your log...</p></div></div>`;
|
||||
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<HTMLElement>("[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<HTMLButtonElement>(".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 = `<div class="login"><div class="notice error">Could not open the local log: ${h(error)}</div></div>`; });
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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<IDBDatabase> | null = null;
|
||||
function database(): Promise<IDBDatabase> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function result<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function allRecords(): Promise<TimeRecord[]> {
|
||||
const db = await database();
|
||||
return result(db.transaction("records").objectStore("records").getAll()) as Promise<TimeRecord[]>;
|
||||
}
|
||||
|
||||
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<OutboxItem[]> {
|
||||
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<number> {
|
||||
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);
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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 || "/");
|
||||
})());
|
||||
});
|
||||
@@ -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<void> | null = null;
|
||||
let rerunRequested = false;
|
||||
|
||||
class SignedOut extends Error {}
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export function syncNow(onChange: (status: SyncStatus, records?: TimeRecord[]) => void): Promise<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<TimeRecord, "startMs" | "targetSeconds" | "tag"> | null, now = Date.now()) {
|
||||
if (!active) return APP_NAME;
|
||||
return `${countdown(dueMs(active) - now)} · ${active.tag} — ${APP_NAME}`;
|
||||
}
|
||||
@@ -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<TimeRecord, "startMs" | "targetSeconds">): 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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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<typeof Bun.spawn>;
|
||||
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);
|
||||
});
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user