diff --git a/src/app.css b/src/app.css deleted file mode 100644 index 61ba367..0000000 --- a/src/app.css +++ /dev/null @@ -1,79 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/src/app/app.css b/src/app/app.css new file mode 100644 index 0000000..b67f2fd --- /dev/null +++ b/src/app/app.css @@ -0,0 +1,3 @@ +@import "@picocss/pico/css/pico.classless.jade.min.css"; +@import "./styles/base.css"; +@import "./styles/countdown.css"; diff --git a/src/app/styles/base.css b/src/app/styles/base.css new file mode 100644 index 0000000..4fb733e --- /dev/null +++ b/src/app/styles/base.css @@ -0,0 +1,16 @@ +:root { + --pico-border-radius: 0.5rem; + --pico-typography-spacing-vertical: 1.5rem; + --pico-form-element-spacing-vertical: 1rem; + --pico-form-element-spacing-horizontal: 1.25rem; +} + +h1 { + --pico-font-family: Pacifico, cursive; + --pico-font-weight: 400; + --pico-typography-spacing-vertical: 0.5rem; +} + +button { + --pico-font-weight: 700; +} \ No newline at end of file diff --git a/src/app/styles/countdown.css b/src/app/styles/countdown.css new file mode 100644 index 0000000..5153b8f --- /dev/null +++ b/src/app/styles/countdown.css @@ -0,0 +1,10 @@ +.countdown-actions { + display: flex; + gap: 0.5rem; +} + +.countdown-error { + min-height: 1.25rem; + color: #b00020; + margin: 0.25rem 0; +} diff --git a/src/features/countdown/api.ts b/src/features/countdown/api.ts new file mode 100644 index 0000000..96c3cb2 --- /dev/null +++ b/src/features/countdown/api.ts @@ -0,0 +1,45 @@ +import {invokeCommand} from "../../shared/tauri/invoke"; +import {CountdownCommand, CountdownSnapshot, CountdownState, Duration} from "./types"; +import {millisToDuration} from "./helper.ts"; + +type CountdownSnapshotDto = { + id: number; + label: string; + duration: number; + state: CountdownState; + start_epoch_ms: number | null; + target_epoch_ms: number | null; +}; + +export async function fetchCountdownSnapshot(): Promise { + let temp = await invokeCommand("countdown_snapshot"); + const duration: Duration = millisToDuration(temp.duration); + return { + id: temp.id, + label: temp.label, + duration: duration, + state: temp.state, + start_epoch: temp.start_epoch_ms !== null ? new Date(temp.start_epoch_ms) : null, + target_epoch: temp.target_epoch_ms !== null ? new Date(temp.target_epoch_ms) : null, + }; +} + +async function invokeCountdownCommand(command: CountdownCommand): Promise { + await invokeCommand(command); +} + +export async function startCountdown(): Promise { + await invokeCountdownCommand("countdown_start"); +} + +export async function pauseCountdown(): Promise { + await invokeCountdownCommand("countdown_pause"); +} + +export async function resumeCountdown(): Promise { + await invokeCountdownCommand("countdown_resume"); +} + +export async function resetCountdown(): Promise { + await invokeCountdownCommand("countdown_reset"); +} diff --git a/src/features/countdown/controller.ts b/src/features/countdown/controller.ts new file mode 100644 index 0000000..025d1d9 --- /dev/null +++ b/src/features/countdown/controller.ts @@ -0,0 +1,48 @@ +import { + fetchCountdownSnapshot, + pauseCountdown, + resetCountdown, + resumeCountdown, + startCountdown, +} from "./api"; +import { createCountdownView } from "./view"; + +export async function initCountdownController(container: HTMLElement): Promise { + const view = createCountdownView(container); + + const refreshSnapshot = async (): Promise => { + try { + const snapshot = await fetchCountdownSnapshot(); + view.setSnapshot(snapshot); + } catch (error) { + view.setError(`snapshot error: ${String(error)}`); + } + }; + + const runAction = async (action: () => Promise): Promise => { + try { + await action(); + await refreshSnapshot(); + } catch (error) { + view.setError(`command error: ${String(error)}`); + } + }; + + view.onStart(() => { + void runAction(startCountdown); + }); + view.onPause(() => { + void runAction(pauseCountdown); + }); + view.onResume(() => { + void runAction(resumeCountdown); + }); + view.onReset(() => { + void runAction(resetCountdown); + }); + view.onRefresh(() => { + void refreshSnapshot(); + }); + + await refreshSnapshot(); +} diff --git a/src/features/countdown/types.ts b/src/features/countdown/types.ts new file mode 100644 index 0000000..d206929 --- /dev/null +++ b/src/features/countdown/types.ts @@ -0,0 +1,18 @@ +import type {Duration} from "../../shared/time/duration"; + +export type CountdownState = "Idle" | "Running" | "Paused" | "Finished"; + +export type CountdownSnapshot = { + id: number; + label: string; + duration: Duration; + state: CountdownState; + start_epoch: Date | null; + target_epoch: Date | null; +} + +export type CountdownCommand = + | "countdown_start" + | "countdown_pause" + | "countdown_resume" + | "countdown_reset"; diff --git a/src/features/countdown/view.ts b/src/features/countdown/view.ts new file mode 100644 index 0000000..7dff44e --- /dev/null +++ b/src/features/countdown/view.ts @@ -0,0 +1,79 @@ +import {formatDuration} from "./helper.ts"; +import {CountdownSnapshot} from "./types.ts"; + +export type CountdownView = { + onStart: (handler: () => void) => void; + onResume: (handler: () => void) => void; + onPause: (handler: () => void) => void; + onReset: (handler: () => void) => void; + onRefresh: (handler: () => void) => void; + setSnapshot: (snapshot: CountdownSnapshot) => void; + setError: (message: string) => void; +}; + +function makeButton(label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.id = "cd-btn-" + label.toLowerCase(); + button.className = "countdown-btn" + button.type = "button"; + button.textContent = label; + return button; +} + +export function createCountdownView(container: HTMLElement): CountdownView { + const panel = document.createElement("section"); + panel.className = "countdown-panel"; + + const title = document.createElement("h2"); + title.textContent = "Countdown Controls"; + + const actions = document.createElement("div"); + actions.className = "countdown-actions"; + + const startButton = makeButton("Start"); + const resumeButton = makeButton("Resume"); + const pauseButton = makeButton("Pause"); + const resetButton = makeButton("Reset"); + const refreshButton = makeButton("Refresh"); + + actions.append(startButton, resumeButton, pauseButton, resetButton, refreshButton); + + const summary = document.createElement("p"); + summary.className = "countdown-summary"; + summary.textContent = "Waiting for snapshot..."; + + const error = document.createElement("p"); + error.className = "countdown-error"; + + const snapshot = document.createElement("pre"); + snapshot.className = "countdown-snapshot"; + + panel.append(title, actions, summary, error, snapshot); + container.appendChild(panel); + + return { + onStart(handler) { + startButton.addEventListener("click", handler); + }, + onResume(handler) { + resumeButton.addEventListener("click", handler); + }, + onPause(handler) { + pauseButton.addEventListener("click", handler); + }, + onReset(handler) { + resetButton.addEventListener("click", handler); + }, + onRefresh(handler) { + refreshButton.addEventListener("click", handler); + }, + setSnapshot(value) { + error.textContent = ""; + summary.textContent = `State: ${value.state} | Remaining: ${formatDuration(value.duration)}`; + snapshot.textContent = JSON.stringify(value, null, 2); + }, + setError(message) { + error.textContent = message; + }, + }; +} diff --git a/src/main.ts b/src/main.ts index 664a057..d219507 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ -import { mount } from 'svelte' -import './app.css' +import {mount} from 'svelte' +import './app/app.css' import App from './App.svelte' const app = mount(App, { - target: document.getElementById('app')!, + target: document.getElementById('app')!, }) export default app diff --git a/src/shared/tauri/invoke.ts b/src/shared/tauri/invoke.ts new file mode 100644 index 0000000..980ffab --- /dev/null +++ b/src/shared/tauri/invoke.ts @@ -0,0 +1,8 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function invokeCommand( + command: string, + payload?: Record +): Promise { + return invoke(command, payload); +} diff --git a/src/shared/time/duration.ts b/src/shared/time/duration.ts new file mode 100644 index 0000000..466eb35 --- /dev/null +++ b/src/shared/time/duration.ts @@ -0,0 +1,19 @@ +export type Duration = { + hours: number; + minutes: number; + seconds: number; + millis: number; +} + +export function formatDuration(duration: Duration): string { + return `${duration.hours.toString().padStart(2, '0')}:${duration.minutes.toString().padStart(2, '0')}:${duration.seconds.toString().padStart(2, '0')}.${duration.millis.toString().padStart(3, '0')}`; +} + +export function millisToDuration(millis: number): Duration { + return { + hours: Math.floor(millis / 3600000), + minutes: Math.floor((millis % 3600000) / 60000), + seconds: Math.floor((millis % 60000) / 1000), + millis: millis % 1000, + } +}