creates new project
This commit is contained in:
parent
184a31ca3e
commit
516920667a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/dev/
|
||||||
|
__pycache__/
|
||||||
|
/hourtrack.ini
|
13
LICENSE
Normal file
13
LICENSE
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
Version 2, December 2004
|
||||||
|
|
||||||
|
Copyright (C) 2025 falsycat <me@falsy.cat>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim or modified
|
||||||
|
copies of this license document, and changing it is allowed as long
|
||||||
|
as the name is changed.
|
||||||
|
|
||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
19
hourtrack/__main__.py
Normal file
19
hourtrack/__main__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from imgui_bundle import imgui, imgui_ctx, immapp
|
||||||
|
|
||||||
|
from hourtrack.compo import GanttView, TriageView
|
||||||
|
|
||||||
|
gctx = GanttView.Context(GanttView.SimpleIO())
|
||||||
|
tctx = TriageView.Context(TriageView.SimpleIO())
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with imgui_ctx.begin("TicketView"):
|
||||||
|
pass
|
||||||
|
with imgui_ctx.begin("GanttView"):
|
||||||
|
GanttView.compo(gctx)
|
||||||
|
with imgui_ctx.begin("TriageView"):
|
||||||
|
TriageView.compo(tctx)
|
||||||
|
|
||||||
|
immapp.run(
|
||||||
|
window_title="hourtrack",
|
||||||
|
gui_function=main
|
||||||
|
)
|
505
hourtrack/compo/GanttView.py
Normal file
505
hourtrack/compo/GanttView.py
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from imgui_bundle import imgui, imgui_ctx
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
DATE_FMT = "%Y-%m-%d"
|
||||||
|
WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- data types ----
|
||||||
|
TicketId = int
|
||||||
|
Point = float
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Period:
|
||||||
|
begin: date
|
||||||
|
end : date
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Hours:
|
||||||
|
estimation: Point = 1
|
||||||
|
actual : Point = .5
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ticket:
|
||||||
|
id : TicketId = 0
|
||||||
|
summary: str = "this is summary"
|
||||||
|
hours : list[Hours] = field(default_factory=list)
|
||||||
|
|
||||||
|
calculated: bool = False
|
||||||
|
selected : bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GUI volatile data (selections, text user modifying, or etc...) ----
|
||||||
|
@dataclass
|
||||||
|
class VolatileData:
|
||||||
|
editing_period_begin: str = ""
|
||||||
|
editing_period_end : str = ""
|
||||||
|
|
||||||
|
editing_cell : None|tuple[TicketId, date] = None
|
||||||
|
editing_cell_text: str = ""
|
||||||
|
|
||||||
|
revision: None|str = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- this I/F allows widget to store or load permanent data
|
||||||
|
class IO(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_period(self) -> Period:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_period(self, p: Period) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_tickets(self) -> list[Ticket]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_hours(self, t: list[TicketId], p: Period, act: None|Point, est: None|Point) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_today(self) -> date:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def sort(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def select_ticket(self, t: TicketId) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_revisions(self) -> list[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def load_revision(self, name: None|str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_revision(self, name: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save_as_new_revision(self, name: str) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SimpleIO(IO):
|
||||||
|
_period : Period
|
||||||
|
_tickets: list[Ticket]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._period = Period(date(2025, 1, 1), date(2025, 2, 1))
|
||||||
|
self._tickets = [
|
||||||
|
Ticket(i, "hello", [
|
||||||
|
*([Hours(0, 0)]*random.randint(1,30)),
|
||||||
|
*([Hours(1, 2)]*random.randint(2,8)),
|
||||||
|
*([Hours(0, 0)]*100),
|
||||||
|
]) for i in range(70)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_period(self) -> Period:
|
||||||
|
return self._period
|
||||||
|
|
||||||
|
def set_period(self, p: Period) -> None:
|
||||||
|
self._period = p
|
||||||
|
|
||||||
|
def get_tickets(self) -> list[Ticket]:
|
||||||
|
return self._tickets
|
||||||
|
|
||||||
|
def get_today(self) -> date:
|
||||||
|
return datetime.now().date()
|
||||||
|
|
||||||
|
def set_hours(self, t: list[TicketId], p: Period, act: None|float, est: None|float) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sort(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def select_ticket(self, t: TicketId) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_revisions(self) -> list[str]:
|
||||||
|
return ["first rev", "second rev"]
|
||||||
|
|
||||||
|
def load_revision(self, name: None|str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete_revision(self, name: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save_as_new_revision(self, name: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- component definition
|
||||||
|
@dataclass
|
||||||
|
class Context:
|
||||||
|
io : IO
|
||||||
|
vola: VolatileData = field(default_factory=lambda: VolatileData())
|
||||||
|
|
||||||
|
def compo(ctx: Context) -> None:
|
||||||
|
io = ctx.io
|
||||||
|
vola = ctx.vola
|
||||||
|
em = imgui.get_font_size()
|
||||||
|
|
||||||
|
# view config / period specs
|
||||||
|
imgui.align_text_to_frame_padding()
|
||||||
|
with imgui_ctx.push_item_width(6*em):
|
||||||
|
period = io.get_period()
|
||||||
|
changed_begin, vola.editing_period_begin = _compo_editable_text(
|
||||||
|
"begin date",
|
||||||
|
period.begin.strftime(DATE_FMT),
|
||||||
|
vola.editing_period_begin,
|
||||||
|
_check_if_str_is_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text("~")
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
period = io.get_period()
|
||||||
|
changed_end, vola.editing_period_end = _compo_editable_text(
|
||||||
|
"end date",
|
||||||
|
period.end.strftime(DATE_FMT),
|
||||||
|
vola.editing_period_end,
|
||||||
|
_check_if_str_is_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed_begin or changed_end:
|
||||||
|
io.set_period(Period(
|
||||||
|
_str2date(vola.editing_period_begin) if vola.editing_period_begin else period.begin,
|
||||||
|
_str2date(vola.editing_period_end) if vola.editing_period_end else period.end,
|
||||||
|
))
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text("/")
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
# revisions
|
||||||
|
with imgui_ctx.push_item_width(6*em):
|
||||||
|
revs = io.get_revisions()
|
||||||
|
imgui.text("revision:")
|
||||||
|
imgui.same_line()
|
||||||
|
if imgui.begin_combo("##rev", vola.revision or "(latest)"):
|
||||||
|
clicked, _ = imgui.selectable("(latest)", False)
|
||||||
|
if clicked:
|
||||||
|
vola.revision = None
|
||||||
|
for name in revs:
|
||||||
|
clicked, _ = imgui.selectable(name, False)
|
||||||
|
if clicked:
|
||||||
|
io.load_revision(name)
|
||||||
|
vola.revision = name
|
||||||
|
imgui.end_combo()
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
if vola.revision is not None:
|
||||||
|
if imgui.button("del"):
|
||||||
|
io.delete_revision(vola.revision)
|
||||||
|
else:
|
||||||
|
if imgui.button("new"):
|
||||||
|
vola.editing_revision_name = ""
|
||||||
|
imgui.open_popup("NEW_REVISION")
|
||||||
|
if imgui.begin_popup("NEW_REVISION"):
|
||||||
|
imgui.set_keyboard_focus_here()
|
||||||
|
entered, vola.editing_revision_name = imgui.input_text(
|
||||||
|
"revision name",
|
||||||
|
vola.editing_revision_name,
|
||||||
|
imgui.InputTextFlags_.enter_returns_true,
|
||||||
|
)
|
||||||
|
if imgui.is_item_deactivated():
|
||||||
|
imgui.close_current_popup()
|
||||||
|
if entered:
|
||||||
|
if io.save_as_new_revision(vola.editing_revision_name):
|
||||||
|
imgui.close_current_popup()
|
||||||
|
imgui.end_popup()
|
||||||
|
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text("/")
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
# tool buttons
|
||||||
|
if imgui.button("sort"):
|
||||||
|
io.sort()
|
||||||
|
|
||||||
|
# gantt
|
||||||
|
today = io.get_today()
|
||||||
|
days = (period.end - period.begin).days + 1
|
||||||
|
past_days = min(days, (period.end - today).days)
|
||||||
|
if days <= 0:
|
||||||
|
imgui.text("invalid period")
|
||||||
|
else:
|
||||||
|
tickets = io.get_tickets()
|
||||||
|
days = [period.begin + timedelta(days=i) for i in range(days)]
|
||||||
|
|
||||||
|
daily_estimations = [sum(t.hours[i].estimation for t in tickets) for i in range(len(days))]
|
||||||
|
daily_actuals = [sum(t.hours[i].actual for t in tickets) for i in range(len(days))]
|
||||||
|
|
||||||
|
daily_estimations_quantiles = _calc_quantiles(daily_estimations)
|
||||||
|
|
||||||
|
table_flags = (
|
||||||
|
imgui.TableFlags_.scroll_x |
|
||||||
|
imgui.TableFlags_.scroll_y |
|
||||||
|
imgui.TableFlags_.borders |
|
||||||
|
imgui.TableFlags_.resizable
|
||||||
|
)
|
||||||
|
with imgui_ctx.begin_table("Table", 2+len(days), table_flags):
|
||||||
|
# column setup
|
||||||
|
imgui.table_setup_scroll_freeze(2, 6)
|
||||||
|
imgui.table_setup_column("#")
|
||||||
|
imgui.table_setup_column(
|
||||||
|
"summary", imgui.TableColumnFlags_.angled_header)
|
||||||
|
for d in days:
|
||||||
|
flags = (
|
||||||
|
imgui.TableColumnFlags_.width_fixed |
|
||||||
|
imgui.TableColumnFlags_.no_resize
|
||||||
|
)
|
||||||
|
if d == today:
|
||||||
|
imgui.table_setup_column(f"{d.day}", flags, 3*1.2*em)
|
||||||
|
else:
|
||||||
|
imgui.table_setup_column(f"{d.day}", flags, 1.2*em)
|
||||||
|
|
||||||
|
# month
|
||||||
|
imgui.table_next_row(imgui.TableRowFlags_.headers)
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.table_next_column()
|
||||||
|
_aligned_text("month ->", 1)
|
||||||
|
for d in days:
|
||||||
|
imgui.table_next_column()
|
||||||
|
if d.day == 1:
|
||||||
|
_aligned_text(f"{d.month}", .5)
|
||||||
|
|
||||||
|
# days
|
||||||
|
imgui.table_next_row(imgui.TableRowFlags_.headers)
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.table_next_column()
|
||||||
|
_aligned_text("day ->", 1)
|
||||||
|
for d in days:
|
||||||
|
imgui.table_next_column()
|
||||||
|
if d == today:
|
||||||
|
with imgui_ctx.push_style_color(imgui.Col_.text, imgui.get_color_u32((.5,1,.5,1))):
|
||||||
|
_aligned_text(f"{d.day}", .5)
|
||||||
|
else:
|
||||||
|
_aligned_text(f"{d.day}", .5)
|
||||||
|
|
||||||
|
# weekday
|
||||||
|
imgui.table_next_row(imgui.TableRowFlags_.headers)
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.table_next_column()
|
||||||
|
_aligned_text("weekday ->", 1)
|
||||||
|
for d in days:
|
||||||
|
imgui.table_next_column()
|
||||||
|
w = d.weekday()
|
||||||
|
if w == 0:
|
||||||
|
color = imgui.get_color_u32((.8, .6, .6, 1))
|
||||||
|
elif w == 6:
|
||||||
|
color = imgui.get_color_u32((.6, .6, .8, 1))
|
||||||
|
else:
|
||||||
|
color = imgui.get_color_u32((.8, .8, .8, 1))
|
||||||
|
|
||||||
|
with imgui_ctx.push_style_color(imgui.Col_.text, color):
|
||||||
|
_aligned_text(f"{WEEKDAYS[w]}", .5)
|
||||||
|
|
||||||
|
# estimation
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_set_column_index(1)
|
||||||
|
_aligned_text("estimation ->", 1)
|
||||||
|
for i,d in enumerate(days):
|
||||||
|
imgui.table_next_column()
|
||||||
|
col = _get_deviation_color(
|
||||||
|
_calc_deviation_coe4(daily_estimations[i], *daily_estimations_quantiles))
|
||||||
|
with imgui_ctx.push_style_color(imgui.Col_.text, col):
|
||||||
|
_aligned_text(f"{daily_estimations[i]}", 1)
|
||||||
|
|
||||||
|
# actual
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_set_column_index(1)
|
||||||
|
_aligned_text("actual ->", 1)
|
||||||
|
for i,d in enumerate(days):
|
||||||
|
imgui.table_next_column()
|
||||||
|
a = daily_actuals[i]
|
||||||
|
e = daily_estimations[i]
|
||||||
|
col = _get_deviation_color(_calc_deviation_coe1(a, e))
|
||||||
|
with imgui_ctx.push_style_color(imgui.Col_.text, col):
|
||||||
|
if d <= today:
|
||||||
|
_aligned_text(f"{a}", 1)
|
||||||
|
|
||||||
|
# header
|
||||||
|
imgui.table_next_row(imgui.TableRowFlags_.headers)
|
||||||
|
imgui.table_next_column()
|
||||||
|
_aligned_text("#", .5)
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text("summary")
|
||||||
|
|
||||||
|
# tickets
|
||||||
|
for t in tickets:
|
||||||
|
imgui.table_next_row()
|
||||||
|
with imgui_ctx.push_id(t.id):
|
||||||
|
imgui.table_next_column()
|
||||||
|
_aligned_text(f"#{t.id}", 1)
|
||||||
|
|
||||||
|
imgui.table_next_column()
|
||||||
|
if imgui.text_link(f"{t.summary}##summary"):
|
||||||
|
io.select_ticket(t.id)
|
||||||
|
|
||||||
|
with imgui_ctx.push_style_var(imgui.StyleVar_.cell_padding, (0, 0)):
|
||||||
|
for i,d in enumerate(days):
|
||||||
|
with imgui_ctx.push_id(f"@{_date2str(d)}"):
|
||||||
|
hours = t.hours[i]
|
||||||
|
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.table_set_bg_color(
|
||||||
|
imgui.TableBgTarget_.cell_bg,
|
||||||
|
imgui.get_color_u32(_get_cell_color(d, today))
|
||||||
|
)
|
||||||
|
|
||||||
|
if vola.editing_cell == (t.id, d):
|
||||||
|
imgui.set_keyboard_focus_here()
|
||||||
|
imgui.set_next_item_width(-1)
|
||||||
|
with imgui_ctx.push_style_var(imgui.StyleVar_.frame_rounding, 0):
|
||||||
|
with imgui_ctx.push_style_var(imgui.StyleVar_.frame_padding, (0, 0)):
|
||||||
|
entered, vola.editing_cell_text = imgui.input_text(
|
||||||
|
"##input", vola.editing_cell_text,
|
||||||
|
imgui.InputTextFlags_.enter_returns_true
|
||||||
|
)
|
||||||
|
imgui.set_item_default_focus()
|
||||||
|
if entered or imgui.is_item_deactivated():
|
||||||
|
vola.editing_cell = None
|
||||||
|
|
||||||
|
error = False
|
||||||
|
try:
|
||||||
|
est, act = _parse_hours(vola.editing_cell_text)
|
||||||
|
except ValueError:
|
||||||
|
error = True
|
||||||
|
if not error:
|
||||||
|
io.set_hours([t.id], Period(d, d), est, act)
|
||||||
|
else:
|
||||||
|
edit = False
|
||||||
|
if d > today:
|
||||||
|
edit = _compo_cell_selectable("##cell", False)
|
||||||
|
if hours.estimation > 0:
|
||||||
|
_aligned_text(f"/{hours.estimation}", 1)
|
||||||
|
|
||||||
|
elif d < today:
|
||||||
|
edit = _compo_cell_selectable("##cell", False)
|
||||||
|
if (hours.estimation > 0 or hours.actual > 0):
|
||||||
|
_aligned_text(f"{hours.actual}/", 1)
|
||||||
|
|
||||||
|
elif d == today:
|
||||||
|
edit = _compo_cell_selectable("##cell", False)
|
||||||
|
|
||||||
|
_aligned_text(f"{hours.actual}", 1., w=1.2*em)
|
||||||
|
imgui.same_line(0, 0)
|
||||||
|
_aligned_text("/", .5, w=1.2*em)
|
||||||
|
imgui.same_line(0, 0)
|
||||||
|
_aligned_text(f"{hours.estimation}", 1., w=1.2*em)
|
||||||
|
|
||||||
|
if edit and not t.calculated:
|
||||||
|
vola.editing_cell = (t.id, d)
|
||||||
|
vola.editing_cell_text = f"{hours.actual}/{hours.estimation}"
|
||||||
|
io.select_ticket(t.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _compo_editable_text(id: str, text: str, editing: str, checker: Callable[[str], None|str]) -> tuple[bool, str]:
|
||||||
|
with imgui_ctx.push_id(id):
|
||||||
|
submitted = False
|
||||||
|
|
||||||
|
if imgui.text_link(text):
|
||||||
|
imgui.open_popup("input")
|
||||||
|
editing = text
|
||||||
|
|
||||||
|
if imgui.begin_popup("input"):
|
||||||
|
imgui.set_keyboard_focus_here()
|
||||||
|
entered, editing = imgui.input_text(
|
||||||
|
id, editing, imgui.InputTextFlags_.enter_returns_true)
|
||||||
|
if err := checker(editing):
|
||||||
|
imgui.text(err)
|
||||||
|
elif entered:
|
||||||
|
submitted = True
|
||||||
|
imgui.close_current_popup()
|
||||||
|
imgui.end_popup()
|
||||||
|
return submitted, editing
|
||||||
|
|
||||||
|
def _aligned_text(text: str, pos: float, w: float = None, func = imgui.text):
|
||||||
|
w = w or imgui.get_content_region_avail().x
|
||||||
|
tw = imgui.calc_text_size(text).x
|
||||||
|
ox = imgui.get_cursor_pos_x()
|
||||||
|
imgui.set_cursor_pos_x(ox + (w-tw)*pos)
|
||||||
|
return func(text)
|
||||||
|
|
||||||
|
def _compo_cell_selectable(name: str, selected: bool, flags: int = 0) -> bool:
|
||||||
|
pos = imgui.get_cursor_pos()
|
||||||
|
imgui.selectable(name, selected, flags)
|
||||||
|
imgui.set_cursor_pos(pos)
|
||||||
|
return imgui.is_item_clicked()
|
||||||
|
|
||||||
|
def _str2date(v: str) -> date:
|
||||||
|
return datetime.strptime(v, DATE_FMT).date()
|
||||||
|
|
||||||
|
def _date2str(v: str) -> date:
|
||||||
|
return v.strftime(DATE_FMT)
|
||||||
|
|
||||||
|
def _check_if_str_is_date(v: str) -> str:
|
||||||
|
try:
|
||||||
|
_str2date(v)
|
||||||
|
return None
|
||||||
|
except ValueError as e:
|
||||||
|
return "invalid format (example: 2002-06-30)"
|
||||||
|
|
||||||
|
def _get_cell_color(d: date, today: date) -> tuple[float, float, float, float]:
|
||||||
|
if d > today:
|
||||||
|
return (0, 0, 0, 0)
|
||||||
|
elif d < today:
|
||||||
|
return (.6, .6, .6, .05)
|
||||||
|
else:
|
||||||
|
return (.3, .9, .3, .2)
|
||||||
|
|
||||||
|
def _get_deviation_color(coe: float) -> tuple[float, float, float, float]:
|
||||||
|
if coe > .8:
|
||||||
|
return (.8, .2, .2, 1)
|
||||||
|
elif coe < -.8:
|
||||||
|
return (.2, .8, .2, 1)
|
||||||
|
else:
|
||||||
|
return (.8, .8, .8, 1)
|
||||||
|
|
||||||
|
def _calc_deviation_coe1(v: float, avg: float) -> float:
|
||||||
|
return _calc_deviation_coe4(v, avg*.8, avg, avg*1.2)
|
||||||
|
|
||||||
|
def _calc_deviation_coe4(v: float, low: float, avg: float, high: float) -> float:
|
||||||
|
if v < avg:
|
||||||
|
return (v - avg) / (avg - low) if avg != low else 0
|
||||||
|
else:
|
||||||
|
return (v - avg) / (high - avg) if avg != high else 0
|
||||||
|
|
||||||
|
def _calc_quantiles(data: list[float]) -> tuple[float, float, float]:
|
||||||
|
return (
|
||||||
|
min(data), sum(data)/len(data), max(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_hours(v: str) -> tuple[float, float]:
|
||||||
|
if v == "":
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
terms = v.split("/")
|
||||||
|
n = len(terms)
|
||||||
|
if n == 2:
|
||||||
|
return _parse_point(terms[0]), _parse_point(terms[1])
|
||||||
|
elif n == 1:
|
||||||
|
return _parse_point(terms[0]), None
|
||||||
|
else:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
def _parse_point(v: str) -> None|Point:
|
||||||
|
if v == "":
|
||||||
|
return None
|
||||||
|
return Point(v)
|
159
hourtrack/compo/TicketEdit.py
Normal file
159
hourtrack/compo/TicketEdit.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from imgui_bundle import imgui, imgui_ctx
|
||||||
|
from typing import Any, List, Set, Tuple
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
Field = Tuple[str, str]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Item:
|
||||||
|
id : int = 0
|
||||||
|
summary: str = "this is summary"
|
||||||
|
detail : str = "helloworld"
|
||||||
|
fields : List[Field] = field(default_factory=list)
|
||||||
|
feats : Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.fields = [
|
||||||
|
("begin", "2024-01-24"),
|
||||||
|
("end", "2025-02-12"),
|
||||||
|
]
|
||||||
|
self.feats = {"subtickets", "gantt"}
|
||||||
|
|
||||||
|
|
||||||
|
class IO(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_item(self) -> Item:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_summary(self, v: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_detail(self, v: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_field(self, idx: int, field: Field) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_field(self, idx: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reorder_field(self, before: int, after: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def compo(io: IO, item: Item = None) -> None:
|
||||||
|
em = imgui.get_font_size()
|
||||||
|
if item is None:
|
||||||
|
item = io.get_item()
|
||||||
|
|
||||||
|
with imgui_ctx.push_id(f"TicketEdit#{item.id}"):
|
||||||
|
if imgui.collapsing_header("basic info", imgui.TreeNodeFlags_.default_open.value):
|
||||||
|
changed, new_summary = imgui.input_text("summary", item.summary)
|
||||||
|
if changed:
|
||||||
|
io.set_summary(new_summary)
|
||||||
|
|
||||||
|
changed, new_detail = imgui.input_text_multiline("detail", item.detail)
|
||||||
|
if changed:
|
||||||
|
io.set_detail(new_detail)
|
||||||
|
|
||||||
|
if imgui.collapsing_header("fields", imgui.TreeNodeFlags_.default_open.value):
|
||||||
|
count = len(item.fields)
|
||||||
|
drag_src = imgui.get_drag_drop_payload_py_id()
|
||||||
|
if drag_src is not None and drag_src.type == "TicketEdit_Fields":
|
||||||
|
drag_src = drag_src.data_id
|
||||||
|
|
||||||
|
for idx in range(0, count+1):
|
||||||
|
field = item.fields[idx] if idx < count else ("", "")
|
||||||
|
|
||||||
|
with imgui_ctx.push_id(idx):
|
||||||
|
with imgui_ctx.begin_group():
|
||||||
|
w = imgui.get_content_region_avail().x
|
||||||
|
|
||||||
|
value_changed = False
|
||||||
|
name_changed = False
|
||||||
|
|
||||||
|
imgui.begin_disabled(idx == count)
|
||||||
|
imgui.text_link(":::")
|
||||||
|
if imgui.begin_drag_drop_source():
|
||||||
|
imgui.set_drag_drop_payload_py_id("TicketEdit_Fields", idx, imgui.Cond_.once.value);
|
||||||
|
imgui.text(f"{field[0]}")
|
||||||
|
imgui.end_drag_drop_source()
|
||||||
|
if imgui.is_item_hovered():
|
||||||
|
imgui.set_tooltip("right click to delete")
|
||||||
|
if imgui.is_mouse_released(1):
|
||||||
|
io.delete_field(idx)
|
||||||
|
imgui.end_disabled()
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
with imgui_ctx.push_item_width(6*em):
|
||||||
|
name_changed, new_name = imgui.input_text_with_hint("##name", "name", field[0])
|
||||||
|
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
with imgui_ctx.push_item_width(w - 10*em):
|
||||||
|
value_changed, new_value = imgui.input_text_with_hint("##value", "value...", field[1])
|
||||||
|
|
||||||
|
if name_changed or value_changed:
|
||||||
|
io.set_field(idx, (new_name, new_value))
|
||||||
|
|
||||||
|
if idx < count and imgui.begin_drag_drop_target():
|
||||||
|
payload = imgui.accept_drag_drop_payload_py_id("TicketEdit_Fields")
|
||||||
|
if payload is not None:
|
||||||
|
io.reorder_field(payload.data_id, idx)
|
||||||
|
imgui.end_drag_drop_target()
|
||||||
|
|
||||||
|
if imgui.collapsing_header("features", imgui.TreeNodeFlags_.default_open.value):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def win(io: IO) -> None:
|
||||||
|
item = io.get_item()
|
||||||
|
|
||||||
|
with imgui_ctx.begin(f"#{item.id}: {item.summary}###TicketEdit#{item.id}"):
|
||||||
|
compo(io, item)
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleIO(IO):
|
||||||
|
_item: Item = Item()
|
||||||
|
|
||||||
|
def get_item(self) -> Item:
|
||||||
|
return deepcopy(self._item)
|
||||||
|
|
||||||
|
def set_summary(self, v: str) -> None:
|
||||||
|
self._item.summary = v
|
||||||
|
|
||||||
|
def set_detail(self, v: str) -> None:
|
||||||
|
self._item.detail = v
|
||||||
|
|
||||||
|
def set_field(self, idx: int, field: Field) -> None:
|
||||||
|
if 0 <= idx and idx < len(self._item.fields):
|
||||||
|
self._item.fields[idx] = field
|
||||||
|
else:
|
||||||
|
self._item.fields.append(field)
|
||||||
|
|
||||||
|
def delete_field(self, idx: int) -> None:
|
||||||
|
del self._item.fields[idx]
|
||||||
|
|
||||||
|
def reorder_field(self, before: int, after: int) -> None:
|
||||||
|
n = len(self._item.fields)
|
||||||
|
if n == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
before = min(max(0, before), n-1)
|
||||||
|
after = min(max(0, after ), n-1)
|
||||||
|
|
||||||
|
if before == after:
|
||||||
|
return
|
||||||
|
|
||||||
|
temp = self._item.fields[before]
|
||||||
|
del self._item.fields[before]
|
||||||
|
self._item.fields.insert(after, temp)
|
214
hourtrack/compo/TriageView.py
Normal file
214
hourtrack/compo/TriageView.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from imgui_bundle import imgui, imgui_ctx
|
||||||
|
|
||||||
|
|
||||||
|
PRIORITY_LABELS = ["A", "B", "C", "D"]
|
||||||
|
DND_PAYLOAD_TYPE = "TICKET"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- data types ----
|
||||||
|
TicketId = int
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ticket:
|
||||||
|
id : TicketId = 0
|
||||||
|
summary : str = "this is tooooo too long summary"
|
||||||
|
selected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- volatile ----
|
||||||
|
@dataclass
|
||||||
|
class VolatileData:
|
||||||
|
selected: TicketId = 0
|
||||||
|
|
||||||
|
editing_new_summaries: list[str] = field(default_factory=lambda: ["", "", "", ""])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- I/O ----
|
||||||
|
class IO(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_tickets(self) -> list[list[Ticket]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def move_ticket(self, t: TicketId, priority: int, index: int) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def select_ticket(self, t: TicketId) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_ticket(self, priority: int, summary: str) -> TicketId:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleIO(IO):
|
||||||
|
_items = [
|
||||||
|
[Ticket(0), Ticket(1), Ticket(2)],
|
||||||
|
[Ticket(3)],
|
||||||
|
[Ticket(4), Ticket(5)],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
_next_id = 100
|
||||||
|
|
||||||
|
def get_tickets(self) -> list[list[Ticket]]:
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def move_ticket(self, t: TicketId, priority: int, index: int) -> bool:
|
||||||
|
for pi,pl in enumerate(self._items):
|
||||||
|
i = next((i for i,v in enumerate(pl) if v.id == t), None)
|
||||||
|
if i is not None:
|
||||||
|
self._items[priority].insert(index, pl[i])
|
||||||
|
if priority == pi and i > index:
|
||||||
|
i += 1
|
||||||
|
del pl[i]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def select_ticket(self, tid: TicketId) -> None:
|
||||||
|
for pl in self._items:
|
||||||
|
for t in pl:
|
||||||
|
t.selected = (tid == t.id)
|
||||||
|
|
||||||
|
def add_ticket(self, priority: int, summary: str) -> TicketId:
|
||||||
|
id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self._items[priority].append(Ticket(id, summary))
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
# ---- component definition ----
|
||||||
|
@dataclass
|
||||||
|
class Context:
|
||||||
|
io : IO
|
||||||
|
vola: VolatileData = field(default_factory=lambda: VolatileData())
|
||||||
|
|
||||||
|
def compo(ctx: Context) -> None:
|
||||||
|
io = ctx.io
|
||||||
|
vola = ctx.vola
|
||||||
|
tickets = io.get_tickets()
|
||||||
|
style = imgui.get_style()
|
||||||
|
|
||||||
|
selected = None
|
||||||
|
dragging = imgui.get_drag_drop_payload_py_id()
|
||||||
|
if dragging is not None:
|
||||||
|
dragging = dragging.data_id if dragging.type == DND_PAYLOAD_TYPE else None
|
||||||
|
|
||||||
|
ln = len(PRIORITY_LABELS)
|
||||||
|
lw = (imgui.get_content_region_avail().x - style.frame_padding.x * (ln+1)) / ln
|
||||||
|
for pi,pl in enumerate(PRIORITY_LABELS):
|
||||||
|
with imgui_ctx.begin_child(pl, (lw, 0), imgui.ChildFlags_.borders):
|
||||||
|
imgui.text(pl)
|
||||||
|
|
||||||
|
basepos = imgui.get_cursor_screen_pos()
|
||||||
|
lh = imgui.get_content_region_avail().y - imgui.get_frame_height_with_spacing()
|
||||||
|
with imgui_ctx.begin_child("list", (0, lh), imgui.ChildFlags_.borders):
|
||||||
|
seps = []
|
||||||
|
for ticket in tickets[pi]:
|
||||||
|
seps.append(imgui.get_cursor_pos_y())
|
||||||
|
|
||||||
|
with imgui_ctx.push_id(ticket.id):
|
||||||
|
_compo_ticket(
|
||||||
|
ticket,
|
||||||
|
selected=ticket.selected,
|
||||||
|
gray=(dragging == ticket.id),
|
||||||
|
)
|
||||||
|
if ticket.selected:
|
||||||
|
selected = ticket.id
|
||||||
|
if vola.selected != ticket.id:
|
||||||
|
imgui.set_scroll_here_x()
|
||||||
|
imgui.set_scroll_here_y()
|
||||||
|
|
||||||
|
if imgui.is_item_clicked():
|
||||||
|
io.select_ticket(ticket.id)
|
||||||
|
selected = ticket.id
|
||||||
|
|
||||||
|
flags = imgui.DragDropFlags_.source_allow_null_id
|
||||||
|
if imgui.begin_drag_drop_source(flags):
|
||||||
|
imgui.set_drag_drop_payload_py_id(DND_PAYLOAD_TYPE, ticket.id)
|
||||||
|
_compo_ticket_detail(ticket)
|
||||||
|
imgui.end_drag_drop_source()
|
||||||
|
seps.append(imgui.get_cursor_pos_y())
|
||||||
|
|
||||||
|
if imgui.begin_drag_drop_target():
|
||||||
|
my = imgui.get_mouse_pos().y - basepos.y
|
||||||
|
if dragging is not None:
|
||||||
|
# find the nearest pos to insert
|
||||||
|
idx, pos = min(enumerate(seps), key=lambda d: abs(d[1]-my))
|
||||||
|
|
||||||
|
# draws line showing pos to insert
|
||||||
|
y = basepos.y + pos - style.item_spacing.y/2
|
||||||
|
imgui.get_window_draw_list().add_line(
|
||||||
|
(imgui.get_item_rect_min().x, y),
|
||||||
|
(imgui.get_item_rect_max().x, y),
|
||||||
|
imgui.get_color_u32((1,1,1,1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# completes the moving
|
||||||
|
if imgui.accept_drag_drop_payload_py_id(DND_PAYLOAD_TYPE) is not None:
|
||||||
|
io.move_ticket(dragging, pi, idx)
|
||||||
|
imgui.end_drag_drop_target()
|
||||||
|
|
||||||
|
# new ticket creation
|
||||||
|
imgui.set_next_item_width(-1)
|
||||||
|
entered, vola.editing_new_summaries[pi] = imgui.input_text_with_hint(
|
||||||
|
"##new_summary",
|
||||||
|
"new ticket summary...",
|
||||||
|
vola.editing_new_summaries[pi],
|
||||||
|
imgui.InputTextFlags_.enter_returns_true,
|
||||||
|
)
|
||||||
|
if entered:
|
||||||
|
io.add_ticket(pi, vola.editing_new_summaries[pi])
|
||||||
|
vola.editing_new_summaries[pi] = ""
|
||||||
|
imgui.same_line()
|
||||||
|
|
||||||
|
vola.selected = selected
|
||||||
|
|
||||||
|
def _compo_ticket(t: Ticket, selected: bool = False, gray: bool = False) -> None:
|
||||||
|
style = imgui.get_style()
|
||||||
|
ltop = imgui.get_cursor_screen_pos()
|
||||||
|
avail = imgui.get_content_region_avail()
|
||||||
|
dlist = imgui.get_window_draw_list()
|
||||||
|
|
||||||
|
with imgui_ctx.begin_group():
|
||||||
|
imgui.dummy((avail.x, 0))
|
||||||
|
imgui.dummy((0, 0))
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text_disabled(f"#{t.id}")
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text_wrapped(t.summary)
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.dummy((0, 0))
|
||||||
|
imgui.dummy((avail.x, 0))
|
||||||
|
|
||||||
|
# tooltip
|
||||||
|
if imgui.is_item_hovered():
|
||||||
|
imgui.set_mouse_cursor(imgui.MouseCursor_.hand)
|
||||||
|
with imgui_ctx.begin_tooltip():
|
||||||
|
_compo_ticket_detail(t)
|
||||||
|
|
||||||
|
# border
|
||||||
|
dlist.add_rect(
|
||||||
|
imgui.get_item_rect_min(),
|
||||||
|
imgui.get_item_rect_max(),
|
||||||
|
imgui.get_color_u32((1,1,1,1)),
|
||||||
|
style.frame_rounding,
|
||||||
|
)
|
||||||
|
|
||||||
|
# background
|
||||||
|
bg = None
|
||||||
|
if gray : bg = (.0,.0,.0,.4)
|
||||||
|
if selected: bg = (.4,.4,.8,.4)
|
||||||
|
if bg is not None:
|
||||||
|
dlist.add_rect_filled(
|
||||||
|
imgui.get_item_rect_min(),
|
||||||
|
imgui.get_item_rect_max(),
|
||||||
|
imgui.get_color_u32(bg),
|
||||||
|
style.frame_rounding,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compo_ticket_detail(t: Ticket):
|
||||||
|
imgui.text_disabled(f"#{t.id}")
|
||||||
|
imgui.text(t.summary)
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
imgui_bundle
|
Loading…
x
Reference in New Issue
Block a user