Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ type ApiError = {
};
type Config = {
default_theme: string;
date_format: string;
clock_format: string;
week_start: string;
};
type NowPlaying = {
currently_playing: boolean;
Expand Down
39 changes: 25 additions & 14 deletions client/app/components/ActivityGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useAppContext } from "~/providers/AppProvider";
import { formatDate } from "~/utils/utils";
import Popup from "./Popup";
import {
apiFetch,
Expand Down Expand Up @@ -55,6 +57,7 @@ export default function ActivityGrid({
queryFn: () => getActivity(args),
});

const { dateFormat, weekStart } = useAppContext();
const width = useWindowWidth();

if (isPending) {
Expand Down Expand Up @@ -90,20 +93,28 @@ export default function ActivityGrid({
listenMap.set(key, item.listens);
}

let firstDay = 1;
try {
// This doesn't work in Firefox
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getWeekInfo
firstDay = new Intl.Locale(navigator.language).getWeekInfo().firstDay;
} catch (err) {
console.log(err);
// Map day name → Intl firstDay integer (1=Mon … 7=Sun)
const weekStartMap: Record<string, number> = {
Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4,
Friday: 5, Saturday: 6, Sunday: 7,
};
let firstDay = weekStartMap[weekStart] ?? 0;
if (!firstDay) {
firstDay = 1;
try {
// This doesn't work in Firefox
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getWeekInfo
firstDay = new Intl.Locale(navigator.language).getWeekInfo().firstDay;
} catch (err) {
console.log(err);
}
}

// Align the grid to calendar weeks (Monday = row 0, Sunday = row 6).
// Align the grid to calendar weeks.
// Column 0 is the oldest week; the last column is the current (partial) week.
const today = new Date();
today.setHours(0, 0, 0, 0);
const daysSinceMonday = (today.getDay() + (7 - firstDay)) % 7; // Mon=0 … Sun=6
const daysSinceMonday = (today.getDay() + (7 - firstDay)) % 7;
const gridStart = new Date(today);
gridStart.setDate(
gridStart.getDate() - daysSinceMonday - (NUM_WEEKS - 1) * 7,
Expand All @@ -130,10 +141,10 @@ export default function ActivityGrid({
const CELL_H = "h-[9px] sm:h-[10px]";
const CELL_GAP = "gap-[2px] md:gap-[3px]";
const CELL_RADIUS = "rounded-[2px]";
const DAY_LABELS =
firstDay == 1
? ["Mon", "", "Wed", "", "Fri", "", "Sun"]
: ["Sun", "", "Tue", "", "Thu", "", "Sat"];
const ALL_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DAY_LABELS = Array.from({ length: 7 }, (_, i) =>
i % 2 === 0 ? ALL_DAYS[(firstDay - 1 + i) % 7] : "",
);

return (
<div className="flex flex-col items-start">
Expand Down Expand Up @@ -165,7 +176,7 @@ export default function ActivityGrid({
position="top"
space={12}
extraClasses="left-2"
inner={`${cell.date.toLocaleDateString()} ${
inner={`${formatDate(cell.date, dateFormat)} ${
cell.listens
} plays`}
>
Expand Down
4 changes: 2 additions & 2 deletions client/app/components/ListensTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function ListensTable({
hideArtists,
onDelete,
}: ListensTableProps) {
const { user } = useAppContext();
const { user, dateFormat, clockFormat } = useAppContext();
const imgColSizeClasses = "py-3 min-w-8 sm:min-w-11";
const imgSize = 32;
const timeColClasses = "text-(--color-fg-tertiary) pr-2 sm:pr-4 sm:text-sm";
Expand Down Expand Up @@ -99,7 +99,7 @@ export default function ListensTable({
className={`text-end whitespace-nowrap ${timeColClasses}`}
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
{timeSince(new Date(item.time), dateFormat, clockFormat)}
</td>
<td className="hidden sm:table">
<button
Expand Down
12 changes: 12 additions & 0 deletions client/app/providers/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface AppContextType {
configurableHomeActivity: boolean;
homeItems: number;
defaultTheme: string;
dateFormat: string;
clockFormat: string;
weekStart: string;
currentVersion: string;
updateAvailable: boolean;
firstActivity: Date | undefined;
Expand All @@ -38,6 +41,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [configurableHomeActivity, setConfigurableHomeActivity] =
useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0);
const [dateFormat, setDateFormat] = useState<string>("");
const [clockFormat, setClockFormat] = useState<string>("");
const [weekStart, setWeekStart] = useState<string>("");

const setUsername = (value: string) => {
if (!user) {
Expand Down Expand Up @@ -69,6 +75,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
} else {
setDefaultTheme("yuu");
}
setDateFormat(cfg.date_format ?? "");
setClockFormat(cfg.clock_format ?? "");
setWeekStart(cfg.week_start ?? "");
});

fetch("/apis/web/v1/first-activity")
Expand Down Expand Up @@ -100,6 +109,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
configurableHomeActivity,
homeItems,
defaultTheme,
dateFormat,
clockFormat,
weekStart,
currentVersion,
updateAvailable,
firstActivity,
Expand Down
8 changes: 4 additions & 4 deletions client/app/routes/MediaItems/MediaLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import DeleteModal from "~/components/modals/DeleteModal";
import EditModal from "~/components/modals/EditModal/EditModal";
import AddListenModal from "~/components/modals/AddListenModal";
import MbzIcon from "~/components/icons/MbzIcon";
import { timeListenedString } from "~/utils/utils";
import { timeListenedString, formatDate, formatDateTime } from "~/utils/utils";
import { Link } from "react-router";
import useWindowWidth from "~/hooks/useWindowWidth";

Expand Down Expand Up @@ -48,7 +48,7 @@ export default function MediaLayout(props: Props) {
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const { user } = useAppContext();
const { user, dateFormat, clockFormat } = useAppContext();

useEffect(() => {
average(props.img.xs, { amount: 1 }).then((color) => {
Expand Down Expand Up @@ -129,9 +129,9 @@ export default function MediaLayout(props: Props) {
</p>
)}
{props.firstListen > 0 && (
<p title={new Date(props.firstListen * 1000).toLocaleString()}>
<p title={formatDateTime(new Date(props.firstListen * 1000), dateFormat, clockFormat)}>
Listening since{" "}
{new Date(props.firstListen * 1000).toLocaleDateString()}
{formatDate(new Date(props.firstListen * 1000), dateFormat)}
</p>
)}
</div>
Expand Down
31 changes: 18 additions & 13 deletions client/app/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,22 @@ const getRewindParams = (): { month: number; year: number } => {
}
};

function timeSince(date: Date) {
function formatDate(date: Date, dateFormat: string = ""): string {
if (!dateFormat) return date.toLocaleDateString();
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = String(date.getFullYear());
return dateFormat.replace("DD", dd).replace("MM", mm).replace("YYYY", yyyy);
}

function formatDateTime(date: Date, dateFormat: string = "", clockFormat: string = ""): string {
const timeOptions: Intl.DateTimeFormatOptions = { timeStyle: "short" };
if (clockFormat === "12h") timeOptions.hour12 = true;
if (clockFormat === "24h") timeOptions.hour12 = false;
return `${formatDate(date, dateFormat)} ${date.toLocaleTimeString([], timeOptions)}`;
}

function timeSince(date: Date, dateFormat: string = "", clockFormat: string = "") {
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);

Expand All @@ -42,18 +57,8 @@ function timeSince(date: Date) {
{ label: "second", seconds: 1 },
];

// 1 day
if (seconds > 86400) {
const dateString = date.toLocaleDateString([], {
month: "numeric",
year: "2-digit",
day: "numeric",
});

const timeString = date.toLocaleTimeString([], {
timeStyle: "short",
});
return `${dateString} ${timeString}`;
return formatDateTime(date, dateFormat, clockFormat);
}

for (const interval of intervals) {
Expand All @@ -66,7 +71,7 @@ function timeSince(date: Date) {
return "just now";
}

export { timeSince };
export { formatDate, formatDateTime, timeSince };

type hsl = {
h: number;
Expand Down
17 changes: 17 additions & 0 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ If the environment variable is defined without **and** with the suffix at the sa
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.

##### KOITO_DATE_FORMAT

- Default: Browser locale
- Description: The date format to use when displaying dates. Must use the `DD`, `MM`, and `YYYY` tokens each exactly once, separated by `/`, `-`, or `.`. When not set, the browser locale determines the format.
- Example: `DD/MM/YYYY` produces `21/04/2026`

##### KOITO_CLOCK_FORMAT

- Default: Browser locale
- Description: The clock format to use when displaying times. Accepted values are `12h` and `24h`. When not set, the browser locale determines the format.

##### KOITO_WEEK_START

- Default: Browser locale
- Description: The first day of the week to use in the activity grid. Accepted values are `Monday`, `Tuesday`, `Wednesday`, `Thursday`, `Friday`, `Saturday`, and `Sunday`. When not set, the browser locale determines the first day (falls back to Monday in Firefox).
- Example: `Monday` for most European locales

##### KOITO_LOGIN_GATE

- Default: `false`
Expand Down
10 changes: 9 additions & 1 deletion engine/handlers/server_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ import (

type ServerConfig struct {
DefaultTheme string `json:"default_theme"`
DateFormat string `json:"date_format"`
ClockFormat string `json:"clock_format"`
WeekStart string `json:"week_start"`
}

func GetCfgHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
utils.WriteJSON(w, http.StatusOK, ServerConfig{
DefaultTheme: cfg.DefaultTheme(),
DateFormat: cfg.DateFormat(),
ClockFormat: cfg.ClockFormat(),
WeekStart: cfg.WeekStart(),
})
}
}
34 changes: 34 additions & 0 deletions internal/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const (
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
LOGIN_GATE_ENV = "KOITO_LOGIN_GATE"
FORCE_TZ = "KOITO_FORCE_TZ"
DATE_FORMAT_ENV = "KOITO_DATE_FORMAT"
CLOCK_FORMAT_ENV = "KOITO_CLOCK_FORMAT"
WEEK_START_ENV = "KOITO_WEEK_START"
)

type config struct {
Expand Down Expand Up @@ -89,6 +92,9 @@ type config struct {
artistSeparators []*regexp.Regexp
loginGate bool
forceTZ *time.Location
dateFormat string
clockFormat string
weekStart string
}

var (
Expand Down Expand Up @@ -192,6 +198,34 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {

cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)

rawDateFormat := getenv(DATE_FORMAT_ENV)
if rawDateFormat != "" {
validFormat := regexp.MustCompile(`^(DD|MM|YYYY)([-/.](DD|MM|YYYY)){2}$`)
if !validFormat.MatchString(rawDateFormat) ||
!strings.Contains(rawDateFormat, "DD") ||
!strings.Contains(rawDateFormat, "MM") ||
!strings.Contains(rawDateFormat, "YYYY") {
return nil, fmt.Errorf("loadConfig: %s must use DD, MM, and YYYY tokens with a single / - or . separator (e.g. DD/MM/YYYY)", DATE_FORMAT_ENV)
}
}
cfg.dateFormat = rawDateFormat

rawClockFormat := getenv(CLOCK_FORMAT_ENV)
if rawClockFormat != "" && rawClockFormat != "12h" && rawClockFormat != "24h" {
return nil, fmt.Errorf("loadConfig: %s must be either '12h' or '24h'", CLOCK_FORMAT_ENV)
}
cfg.clockFormat = rawClockFormat

validWeekDays := map[string]bool{
"Monday": true, "Tuesday": true, "Wednesday": true, "Thursday": true,
"Friday": true, "Saturday": true, "Sunday": true,
}
rawWeekStart := getenv(WEEK_START_ENV)
if rawWeekStart != "" && !validWeekDays[rawWeekStart] {
return nil, fmt.Errorf("loadConfig: %s must be one of: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday", WEEK_START_ENV)
}
cfg.weekStart = rawWeekStart

cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" {
cfg.configDir = "/etc/koito"
Expand Down
18 changes: 18 additions & 0 deletions internal/cfg/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,24 @@ func DefaultTheme() string {
return globalConfig.defaultTheme
}

func DateFormat() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.dateFormat
}

func ClockFormat() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.clockFormat
}

func WeekStart() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.weekStart
}

func DeezerDisabled() bool {
lock.RLock()
defer lock.RUnlock()
Expand Down
Loading