generated at
scrapbox上でリアルタイムに残り時間を表示するUserScript@0.0.2

表示項目を増やす
既存
完了したタスクの合計時間
未完了タスクの残り時間
変更
今日の空き時間
h単位に丸める
新規
今やっているタスクの名前
なければ、新規作成フォームを表示する
この実装は後回し
今やっているタスクの残り時間
なければ出さない
タスクに消費した時間を表示したほうがわかりやすいかな
次のタスクの名前
次のタスクまでの残り時間
ちょーっと表示項目が多いなtakker
不要な物を減らしたい

ゲージを表示したいかも
今の時間を赤線で示す
下に残り時間などの統計値を表示する


$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/scrapbox上でリアルタイムに残り時間を表示するUserScript@0.0.2/main.ts
main.ts
import { toTitle } from "../takker99%2Ftakker-scheduler/diary.ts"; import { getPage } from "../scrapbox-userscript-std/rest.ts"; import { getPersonalTimeStatus, PersonalTimeStatus } from "./mod.ts"; import { write, read, listen } from "./storage.ts"; import { makeDashboard } from "./ui.ts"; import { useStatusBar } from "../scrapbox-userscript-std/dom.ts"; import { subDays } from "../date-fns/subDays.ts"; const dashboard = makeDashboard(); const viewer = dashboard.shadowRoot!.getElementById("container")!; const load = async (now: Date): Promise<void> => { // 日をまたぐタスクを回収するために、前日の日記ページも取得する const results = await Promise.all([ getPage("takker-memex", toTitle(subDays(now, 1))), getPage("takker-memex", toTitle(now)), ]); if (!results[0].ok || !results[1].ok) return; const status = getPersonalTimeStatus( [...results[0].value.lines.slice(1), ...results[1].value.lines.slice(1)], now, ); write(status); }; const zero = (n: number): string => `${n}`.padStart(2, "0"); const format = (n: number): string => { const m = Math.abs(n); const seconds = m % 60; const minutes = ((m - seconds) % 3600) / 60; const hours = Math.floor(m / 3600); return `${n < 0 ? "-" : ""}${zero(hours)}h${zero(minutes)}m${zero(seconds)}s`; }; const hour = (n: number): string => (n / 3600).toFixed(1); const render = ({ done, todo }: PersonalTimeStatus, now: Date): void => { const free = Math.floor((24 - now.getHours()) * 3600 - now.getMinutes() * 60 - now.getSeconds() - todo); viewer.textContent = `✅: ${hour(done)}h, ⬜: ${hour(todo)}h, 🆓: ${hour(free)}`; }; const reload = async () => { await load(new Date()); render(read(), new Date()); }; await reload(); dashboard.shadowRoot!.getElementById("reload")!.addEventListener("click", reload); listen(() => render(read(), new Date())); setInterval(() => render(read(), new Date()), 1000);

storage.ts
import type { PersonalTimeStatus } from "./mod.ts"; const storageName = "takker-scheduler-dashboard"; export const read = (): PersonalTimeStatus => JSON.parse(localStorage.getItem(storageName) ?? ""); export const write = (status: PersonalTimeStatus): void => { localStorage.setItem(storageName, JSON.stringify(status)); }; export const listen = (callback: CallableFunction): void => { globalThis.addEventListener("storage", (e: StorageEvent) => { if (e.key !== storageName) return; callback(); }); };

mod.ts
import { parseLines, TaskBlock } from "../takker99%2Ftakker-scheduler/deps.ts"; import { startOfDay } from "../date-fns/startOfDay.ts"; import { addDays } from "../date-fns/addDays.ts"; import { hasRecord, split } from "./task.ts"; import { BaseLine } from "../scrapbox-jp%2Ftypes/userscript.ts"; export interface PersonalTimeStatus { todo: number; done: number; } export const getPersonalTimeStatus = (lines: BaseLine[], now: Date) => { const tasks: TaskBlock[] = []; for (let block of parseLines(lines)) { if (typeof block === "string") continue; // 見積もりできないタスクは除外 if (!block.plan.duration && !hasRecord(block)) continue; // 今日以降のタスクを切り出す const [, tail] = split(block, startOfDay(now)); if (!tail) continue; block = tail; // 今日までのタスクを切り出す const [head] = split(block, addDays(startOfDay(now), 1)); if (!head) continue; block = head; tasks.push(block); } // 完了したタスクの消費時間の合計 const done = tasks.reduce( (sum, task) => sum + (hasRecord(task) ? Math.floor( (task.record.end.getTime() - task.record.start.getTime()) / 1000 ) : 0 ), 0 ); // 未完了タスクの見積もり時間の合計 const todo = tasks.reduce( (sum, task) => sum + (hasRecord(task) ? 0 : (task.plan.duration ?? 0)), 0 ); return { done, todo }; };


task.ts
import { Task } from "../takker99%2Ftakker-scheduler/deps.ts"; import { startOfDay } from "../date-fns/startOfDay.ts"; import { addDays } from "../date-fns/addDays.ts"; import { addSeconds } from "../date-fns/addSeconds.ts"; import { isBefore } from "../date-fns/isBefore.ts"; export const hasRecord = (task: Task): task is Omit<Task, "record"> & { record: Required<Task["record"]>; } => task.record.start !== undefined && task.record.end !== undefined; /** タスクを特定日時で分割する * * まず記録時間で分割できるか試す。もし記録がなければ予定時刻から分割を試す * * - 記録で分割するときは、予定を分割しないので注意 * - そのうち予定も分割するように変更するかも * * 記録時間も予定時間もないか、分割時刻を跨いでいなければ分割せずに返す * * @param task 分割したいタスク * @param separator 分割時刻 * @return 分割したタスクのリスト */ export const split = <T extends Task>(task: T, separator: Date) : [undefined, T] | [T, undefined] | [T, T] => { // 予定時刻から区切る if(!hasRecord(task)) { // 予定時刻が設定されていないとき if(!task.plan.duration || !task.plan.start) { return isBefore(task.plan.start ?? task.base, separator) ? [task, undefined] : [undefined, task]; } const end = addSeconds(task.plan.start, task.plan.duration); if (isBefore(end, separator)) return [task, undefined]; if (isBefore(separator, task.plan.start)) return [undefined, task]; const head = structuredClone(task); head.plan.duration = Math.floor((separator.getTime() - task.plan.start.getTime()) / 1000); const tail = structuredClone(task); tail.base = startOfDay(separator); tail.plan.start = new Date(separator); tail.plan.duration = Math.floor((end.getTime() - separator.getTime()) / 1000); return [head, tail]; } // 記録から区切る if (isBefore(task.record.end, separator)) return [task, undefined]; if (isBefore(separator, task.record.start)) return [undefined, task]; const head = structuredClone(task); head.record.end =new Date(separator); const tail = structuredClone(task); tail.record.start = new Date(separator); return [head, tail]; };

ui.ts
const id = "takker-scheduler-dashboard"; export const makeDashboard = (): HTMLDivElement => { const div_ = document.getElementById(id); if (div_ instanceof HTMLDivElement) return div_; const div = document.createElement("div"); div.id = id; const shadowRoot = div.attachShadow({ mode: "open" }); shadowRoot.innerHTML = `<style> :host { position: fixed; top: 50px; left: 10px; z-index: 500; padding: 2px; border: solid 1px black; border-radius: 4px; background-color: var(--page-bg, #fefefe); color: var(--page-text-color, #4a4a4a); font: "Roboto",Helvetica,Arial,"Hiragino Sans",sans-serif; font-size: 1em; display: flex; } #reload { background: unset; color: unset; border: unset; cursor: pointer; } </style> <button id="reload" title="reload">🔄</button><div id="container"></div>`; document.body.append(div); return div; };

#2022-12-09 06:18:25
#2022-12-08 11:13:16