scrapbox上でリアルタイムに残り時間を表示するUserScript@0.0.2
表示項目を増やす
既存
完了したタスクの合計時間
未完了タスクの残り時間
変更
今日の空き時間
h単位に丸める
新規
今やっているタスクの名前
なければ、新規作成フォームを表示する
この実装は後回し
今やっているタスクの残り時間
なければ出さない
タスクに消費した時間を表示したほうがわかりやすいかな
次のタスクの名前
次のタスクまでの残り時間
ちょーっと表示項目が多いな
不要な物を減らしたい
ゲージを表示したいかも
今の時間を赤線で示す
下に残り時間などの統計値を表示する
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/scrapbox上でリアルタイムに残り時間を表示するUserScript@0.0.2/main.ts
main.tsimport { 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.tsimport 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.tsimport { 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.tsimport { 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.tsconst 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;
};