This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+67
View File
@@ -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" }),
};
+47
View File
@@ -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;
}
+82
View File
@@ -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];
}
+64
View File
@@ -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;
}
+67
View File
@@ -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();
}
}
+231
View File
@@ -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[] };
}
+57
View File
@@ -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];
}