init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The one place that talks to the backend.
|
||||
*
|
||||
* Every path is relative: in production the API and the UI share an origin, and
|
||||
* in development Vite proxies /api, so neither build needs to know a host. The
|
||||
* optional shared secret is kept in localStorage and sent on every call.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "pt.apiToken";
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body) headers.set("Content-Type", "application/json");
|
||||
const token = getToken();
|
||||
if (token) headers.set("X-API-Token", token);
|
||||
|
||||
const response = await fetch(path, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body?.detail === "string") detail = body.detail;
|
||||
else if (Array.isArray(body?.detail)) {
|
||||
detail = body.detail
|
||||
.map((item: { loc?: string[]; msg?: string }) =>
|
||||
`${(item.loc ?? []).slice(1).join(".")}: ${item.msg ?? ""}`.trim(),
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
} catch {
|
||||
/* a non-JSON error body is fine — the status line says enough */
|
||||
}
|
||||
throw new ApiError(detail, response.status);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T,>(path: string) => request<T>(path),
|
||||
post: <T,>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "POST", body: body === undefined ? "{}" : JSON.stringify(body) }),
|
||||
put: <T,>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T,>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
del: <T,>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Formatting shared by every page, so numbers read the same everywhere. */
|
||||
|
||||
export function clock(totalSeconds: number): string {
|
||||
const safe = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(safe / 3600);
|
||||
const minutes = Math.floor((safe % 3600) / 60);
|
||||
const seconds = safe % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
export function humanMinutes(minutes: number): string {
|
||||
const rounded = Math.round(minutes);
|
||||
const hours = Math.floor(rounded / 60);
|
||||
const rest = rounded % 60;
|
||||
if (hours && rest) return `${hours}h ${rest}m`;
|
||||
if (hours) return `${hours}h`;
|
||||
return `${rest}m`;
|
||||
}
|
||||
|
||||
export const KIND_LABEL: Record<string, string> = {
|
||||
work: "Focus",
|
||||
short_break: "Short break",
|
||||
long_break: "Long break",
|
||||
};
|
||||
|
||||
export const BUCKET_LABEL: Record<string, string> = {
|
||||
overdue: "Overdue",
|
||||
today: "Due today",
|
||||
upcoming: "Coming up",
|
||||
someday: "No deadline",
|
||||
};
|
||||
|
||||
export function dueLabel(task: { due_date: string | null; days_overdue: number }): string {
|
||||
if (!task.due_date) return "";
|
||||
if (task.days_overdue > 0) return `${task.days_overdue}d overdue`;
|
||||
return task.due_date;
|
||||
}
|
||||
|
||||
export function shortDate(iso: string): string {
|
||||
return iso.slice(5).replace("-", "/");
|
||||
}
|
||||
|
||||
export function weekdayOf(iso: string): number {
|
||||
// Monday = 0, matching the backend's schedule days.
|
||||
return (new Date(`${iso}T00:00:00`).getDay() + 6) % 7;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Small data hooks. No query library: the app has a handful of endpoints and
|
||||
* one rule — the server owns the truth, the client polls it and ticks locally
|
||||
* between polls so the countdown stays smooth without hammering the API.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
export interface Resource<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
set: (value: T) => void;
|
||||
}
|
||||
|
||||
export function useResource<T>(path: string | null, pollMs = 0): Resource<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(path !== null);
|
||||
const alive = useRef(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!path) return;
|
||||
try {
|
||||
const value = await api.get<T>(path);
|
||||
if (!alive.current) return;
|
||||
setData(value);
|
||||
setError(null);
|
||||
} catch (exc) {
|
||||
if (alive.current) setError(exc instanceof Error ? exc.message : String(exc));
|
||||
} finally {
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void reload();
|
||||
if (!pollMs) return () => void (alive.current = false);
|
||||
const handle = window.setInterval(() => void reload(), pollMs);
|
||||
return () => {
|
||||
alive.current = false;
|
||||
window.clearInterval(handle);
|
||||
};
|
||||
}, [reload, pollMs]);
|
||||
|
||||
return { data, error, loading, reload, set: setData };
|
||||
}
|
||||
|
||||
/** A 1 Hz tick, used to count down between server polls. */
|
||||
export function useTick(active: boolean): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handle = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [active]);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/** Remembers a value in localStorage (used for per-device UI preferences). */
|
||||
export function useLocalState<T>(key: string, initial: T): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored === null) return initial;
|
||||
try {
|
||||
return JSON.parse(stored) as T;
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
const update = useCallback(
|
||||
(next: T) => {
|
||||
setValue(next);
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return [value, update];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The settings context.
|
||||
*
|
||||
* One fetch at start-up, shared by every page, with a `save` that sends a patch
|
||||
* and swaps in whatever the server echoes back — so the secret masking stays
|
||||
* consistent and no page keeps a stale copy.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "./api";
|
||||
import type { AppSettings, SettingsResponse } from "./types";
|
||||
|
||||
interface SettingsContextValue {
|
||||
settings: AppSettings | null;
|
||||
defaults: SettingsResponse["defaults"] | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
save: (patch: Record<string, unknown>) => Promise<AppSettings>;
|
||||
}
|
||||
|
||||
const Context = createContext<SettingsContextValue | null>(null);
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [defaults, setDefaults] = useState<SettingsResponse["defaults"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const body = await api.get<SettingsResponse>("/api/settings");
|
||||
setSettings(body.settings);
|
||||
setDefaults(body.defaults);
|
||||
setError(null);
|
||||
} catch (exc) {
|
||||
setError(exc instanceof Error ? exc.message : String(exc));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (patch: Record<string, unknown>) => {
|
||||
const body = await api.put<{ settings: AppSettings }>("/api/settings", patch);
|
||||
setSettings(body.settings);
|
||||
return body.settings;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ settings, defaults, error, loading, reload, save }),
|
||||
[settings, defaults, error, loading, reload, save],
|
||||
);
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function useSettings(): SettingsContextValue {
|
||||
const value = useContext(Context);
|
||||
if (!value) throw new Error("useSettings outside SettingsProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Alarms and ticks, synthesised with the Web Audio API.
|
||||
*
|
||||
* No audio files to ship or to 404, and no autoplay surprise: the context is
|
||||
* created on the first user gesture (starting a timer counts) and reused.
|
||||
*/
|
||||
let context: AudioContext | null = null;
|
||||
|
||||
function ensureContext(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!context) {
|
||||
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
context = new Ctor();
|
||||
}
|
||||
if (context.state === "suspended") void context.resume();
|
||||
return context;
|
||||
}
|
||||
|
||||
function beep(frequency: number, seconds: number, volume: number, at = 0): void {
|
||||
const ctx = ensureContext();
|
||||
if (!ctx) return;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.value = frequency;
|
||||
const start = ctx.currentTime + at;
|
||||
// A short ramp instead of a hard stop; a clipped sine clicks.
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, volume), start + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + seconds);
|
||||
oscillator.connect(gain).connect(ctx.destination);
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + seconds + 0.05);
|
||||
}
|
||||
|
||||
export const sound = {
|
||||
/** Work interval finished: three rising notes. */
|
||||
workDone(volume: number): void {
|
||||
const level = volume / 100;
|
||||
beep(660, 0.18, 0.25 * level, 0);
|
||||
beep(880, 0.18, 0.25 * level, 0.2);
|
||||
beep(1046, 0.4, 0.25 * level, 0.4);
|
||||
},
|
||||
/** Break finished: two falling notes — back to it. */
|
||||
breakDone(volume: number): void {
|
||||
const level = volume / 100;
|
||||
beep(880, 0.18, 0.22 * level, 0);
|
||||
beep(587, 0.35, 0.22 * level, 0.2);
|
||||
},
|
||||
tick(volume: number): void {
|
||||
beep(1200, 0.03, 0.04 * (volume / 100));
|
||||
},
|
||||
unlock(): void {
|
||||
ensureContext();
|
||||
},
|
||||
};
|
||||
|
||||
/** A desktop notification, when the browser (or Electron) allows one. */
|
||||
export function notify(title: string, body: string): void {
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(title, { body, icon: undefined });
|
||||
} else if (Notification.permission !== "denied") {
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/** Shapes returned by the API. Only the fields the UI actually reads. */
|
||||
|
||||
export type TimerKind = "work" | "short_break" | "long_break";
|
||||
|
||||
export interface TimerSession {
|
||||
id: number;
|
||||
kind: TimerKind;
|
||||
status: "running" | "paused" | "completed" | "abandoned";
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
planned_seconds: number;
|
||||
elapsed_seconds: number;
|
||||
remaining_seconds: number;
|
||||
interruptions: number;
|
||||
cycle_index: number;
|
||||
task: { source: string; external_id: string; title: string } | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
current: TimerSession | null;
|
||||
next_kind: TimerKind;
|
||||
completed_today: number;
|
||||
goal: number;
|
||||
cycle_position: number;
|
||||
cycles_before_long_break: number;
|
||||
durations: Record<TimerKind, number>;
|
||||
server_time: string;
|
||||
finished?: TimerSession | null;
|
||||
auto_started?: TimerSession | null;
|
||||
celebration?: string | null;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
source: string;
|
||||
source_label: string;
|
||||
external_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string | null;
|
||||
project: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
due_date: string | null;
|
||||
start_date: string | null;
|
||||
labels: string[];
|
||||
is_done: boolean;
|
||||
pinned: boolean;
|
||||
stale: boolean;
|
||||
estimated_pomodoros: number | null;
|
||||
days_overdue: number;
|
||||
bucket: "overdue" | "today" | "upcoming" | "someday";
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
date: string;
|
||||
tasks: Task[];
|
||||
counts: Record<string, number>;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
refreshed_at: string;
|
||||
date: string;
|
||||
total: number;
|
||||
sources: { source: string; label: string; ok: boolean; error: string | null; count: number }[];
|
||||
}
|
||||
|
||||
export interface BlockerPolicy {
|
||||
active: boolean;
|
||||
reason: string;
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
strict: boolean;
|
||||
redirect_ip: string;
|
||||
domains: string[];
|
||||
apps: string[];
|
||||
kill_apps: boolean;
|
||||
poll_seconds: number;
|
||||
all_domains: string[];
|
||||
all_apps: string[];
|
||||
session_id: number | null;
|
||||
}
|
||||
|
||||
export interface BlockEvents {
|
||||
date: string;
|
||||
total: number;
|
||||
by_target: { target: string; count: number }[];
|
||||
events: { id: number; kind: string; target: string; action: string; at: string; details: string | null }[];
|
||||
}
|
||||
|
||||
export interface DayMetrics {
|
||||
date: string;
|
||||
pomodoros: number;
|
||||
goal: number;
|
||||
goal_pct: number;
|
||||
goal_met: boolean;
|
||||
focus_minutes: number;
|
||||
break_minutes: number;
|
||||
wasted_minutes: number;
|
||||
abandoned: number;
|
||||
breaks_taken: number;
|
||||
interruptions: number;
|
||||
completion_rate: number;
|
||||
longest_stretch_minutes: number;
|
||||
first_start: string | null;
|
||||
last_end: string | null;
|
||||
by_hour: { hour: number; count: number }[];
|
||||
peak_hour: number | null;
|
||||
tasks_completed: { id: number; title: string; source: string; project: string | null; url: string | null }[];
|
||||
tasks_completed_count: number;
|
||||
time_by_task: { title: string; minutes: number }[];
|
||||
blocked_attempts: number;
|
||||
blocked_top: { target: string; count: number }[];
|
||||
sessions: {
|
||||
id: number;
|
||||
kind: TimerKind;
|
||||
status: string;
|
||||
started_at: string;
|
||||
minutes: number;
|
||||
task: string | null;
|
||||
interruptions: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
metrics: DayMetrics;
|
||||
streak: { current: number; best: number; goal_streak: number; total_days: number };
|
||||
average_7d: { pomodoros: number; focus_minutes: number };
|
||||
lifetime: { pomodoros: number; focus_minutes: number; focus_hours: number; tasks_completed: number; blocked_attempts: number };
|
||||
comparison: { yesterday: number; previous_best: number };
|
||||
achievements: { code: string; emoji: string; title: string; detail: string }[];
|
||||
fun_facts: string[];
|
||||
verdict: string;
|
||||
open_tasks: Record<string, number>;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
today: DayMetrics;
|
||||
streak: { current: number; best: number; goal_streak: number; total_days: number };
|
||||
lifetime: DailyReport["lifetime"];
|
||||
average_7d: { pomodoros: number; focus_minutes: number };
|
||||
range: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[];
|
||||
heatmap: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
timezone: string;
|
||||
week_starts_monday: boolean;
|
||||
pomodoro: {
|
||||
work_minutes: number;
|
||||
short_break_minutes: number;
|
||||
long_break_minutes: number;
|
||||
cycles_before_long_break: number;
|
||||
auto_start_breaks: boolean;
|
||||
auto_start_work: boolean;
|
||||
daily_goal_pomodoros: number;
|
||||
strict_mode: boolean;
|
||||
allow_pause: boolean;
|
||||
count_interruptions: boolean;
|
||||
tick_sound: boolean;
|
||||
alarm_sound: boolean;
|
||||
alarm_volume: number;
|
||||
desktop_notifications: boolean;
|
||||
keep_on_top_during_work: boolean;
|
||||
};
|
||||
sources: {
|
||||
jira: Record<string, unknown> & { enabled: boolean; base_url: string; email: string; api_token: string; extra_jql: string; start_date_field: string; include_all_open: boolean; writeback_done: boolean; done_transition_name: string; max_issues: number };
|
||||
google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean; writeback_done: boolean };
|
||||
todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string; writeback_done: boolean };
|
||||
laundry: { enabled: boolean; base_url: string; session_cookie: string };
|
||||
refresh_interval_minutes: number;
|
||||
request_timeout_seconds: number;
|
||||
};
|
||||
blocker: {
|
||||
enabled: boolean;
|
||||
mode: "during_work" | "always" | "scheduled";
|
||||
block_during_breaks: boolean;
|
||||
blocked_domains: string[];
|
||||
allowed_domains: string[];
|
||||
blocked_apps: string[];
|
||||
kill_blocked_apps: boolean;
|
||||
schedule: { label: string; start: string; end: string; days: number[] }[];
|
||||
strict: boolean;
|
||||
redirect_ip: string;
|
||||
app_poll_seconds: number;
|
||||
notify_on_block: boolean;
|
||||
};
|
||||
notifications: {
|
||||
telegram: {
|
||||
enabled: boolean;
|
||||
bot_token: string;
|
||||
chat_id: string;
|
||||
commands_enabled: boolean;
|
||||
daily_report: boolean;
|
||||
daily_report_time: string;
|
||||
morning_briefing: boolean;
|
||||
morning_briefing_time: string;
|
||||
weekly_report: boolean;
|
||||
weekly_report_weekday: number;
|
||||
celebrate_every_n_pomodoros: number;
|
||||
nudge_enabled: boolean;
|
||||
nudge_after_idle_minutes: number;
|
||||
nudge_window_start: string;
|
||||
nudge_window_end: string;
|
||||
nudge_max_per_day: number;
|
||||
goal_warning: boolean;
|
||||
quiet_hours: { enabled: boolean; start: string; end: string };
|
||||
};
|
||||
slack: { enabled: boolean; webhook_url: string };
|
||||
report: {
|
||||
tone: "friendly" | "coach" | "drill_sergeant" | "dry";
|
||||
language: "en" | "ru";
|
||||
include_funny_stats: boolean;
|
||||
include_task_list: boolean;
|
||||
include_blocked_sites: boolean;
|
||||
include_achievements: boolean;
|
||||
include_comparison: boolean;
|
||||
max_tasks_listed: number;
|
||||
};
|
||||
};
|
||||
appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean };
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
settings: AppSettings;
|
||||
defaults: { blocked_domains: string[]; blocked_apps: string[] };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Chart colours.
|
||||
*
|
||||
* These are the validated data-viz slots, not the Material theme's palette: the
|
||||
* UI's accent can be anything, but a chart has to stay readable for colour-blind
|
||||
* readers in both themes. Work/short/long are categorical slots 1–3 (validated
|
||||
* all-pairs in both modes); magnitude charts use the single-hue blue ramp.
|
||||
*/
|
||||
|
||||
export interface VizPalette {
|
||||
work: string;
|
||||
shortBreak: string;
|
||||
longBreak: string;
|
||||
sequential: string[];
|
||||
grid: string;
|
||||
axis: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
const LIGHT: VizPalette = {
|
||||
work: "#2a78d6",
|
||||
shortBreak: "#eb6834",
|
||||
longBreak: "#1baf7a",
|
||||
// blue 100 → 600, light → dark: "more pomodoros" reads as "darker".
|
||||
sequential: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"],
|
||||
grid: "rgba(0,0,0,0.08)",
|
||||
axis: "rgba(0,0,0,0.45)",
|
||||
surface: "#fcfcfb",
|
||||
};
|
||||
|
||||
const DARK: VizPalette = {
|
||||
work: "#3987e5",
|
||||
shortBreak: "#d95926",
|
||||
longBreak: "#199e70",
|
||||
sequential: ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"],
|
||||
grid: "rgba(255,255,255,0.10)",
|
||||
axis: "rgba(255,255,255,0.55)",
|
||||
surface: "#1a1a19",
|
||||
};
|
||||
|
||||
export function vizPalette(dark: boolean): VizPalette {
|
||||
return dark ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
export function kindColor(kind: string, palette: VizPalette): string {
|
||||
if (kind === "work") return palette.work;
|
||||
if (kind === "long_break") return palette.longBreak;
|
||||
return palette.shortBreak;
|
||||
}
|
||||
|
||||
/** Pick a ramp step for a count, 0 meaning "empty". */
|
||||
export function rampStep(count: number, goal: number, palette: VizPalette): string | null {
|
||||
if (count <= 0) return null;
|
||||
const ratio = goal > 0 ? count / goal : count / 8;
|
||||
const index = Math.min(palette.sequential.length - 1, Math.max(0, Math.round(ratio * (palette.sequential.length - 1))));
|
||||
return palette.sequential[index];
|
||||
}
|
||||
Reference in new issue
Block a user