scrapbox上でリアルタイムに残り時間を表示するUserScript@0.0.1
UI
場所
表示する情報
完了したタスクの合計時間
今日未完了のタスクの合計見積もり時間
残り時間から今日未完了のタスクの合計見積もり時間を引いたもの
マイナスのときは赤字で表示する
データの更新
完了/未完了タスクの更新は、とりあえず手動にする
自動処理まで書く余裕がない
ポチポチボタン押すだけなら簡単
code: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/mod.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 (!results0.ok || !results1.ok) return; const status = getPersonalTimeStatus(
[...results0.value.lines.slice(1), ...results1.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, 🆓: ${format(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);
code: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();
});
};
code:mod.ts
import { parseLines, TaskBlock } from "../takker99%2Ftakker-scheduler/deps.ts";
import { startOfDay, addDays } from "../date-fns/mod.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 };
};
code:task.ts
import { Task } from "../takker99%2Ftakker-scheduler/deps.ts";
import { startOfDay, addDays, addSeconds, isBefore } from "../date-fns/mod.ts";
export const hasRecord = (task: Task): task is Omit<Task, "record"> & {
} => task.record.start !== undefined && task.record.end !== undefined;
/** タスクを特定日時で分割する
*
* まず記録時間で分割できるか試す。もし記録がなければ予定時刻から分割を試す
*
* - 記録で分割するときは、予定を分割しないので注意
* - そのうち予定も分割するように変更するかも
*
* 記録時間も予定時間もないか、分割時刻を跨いでいなければ分割せずに返す
*
* @param task 分割したいタスク
* @param separator 分割時刻
* @return 分割したタスクのリスト
*/
// 予定時刻から区切る
if(!hasRecord(task)) {
// 予定時刻が設定されていないとき
if(!task.plan.duration || !task.plan.start) {
return isBefore(task.plan.start ?? task.base, separator)
}
const end = addSeconds(task.plan.start, task.plan.duration);
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);
}
// 記録から区切る
const head = structuredClone(task);
head.record.end =new Date(separator);
const tail = structuredClone(task);
tail.record.start = new Date(separator);
};
code: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;
font: "Roboto",Helvetica,Arial,"Hiragino Sans",sans-serif;
font-size: 1em;
display: flex;
}
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;
};