Federation (#327)
* initial commit federation * more federation * more federation * more federation * completes follow, accept, undo * add trail create activity * process trail create activity * more trail create activity * adds missing endpoints * adds update and delete activity * adds comment activities * adds activities back * adds summit logs activities * deletes follow counts table * fixes migrations * fixes migrations * fixes migrations * fixes migrations * adds remote profiles * ctd * ctd * we are getting closer... * adds public summit logs * fixes federated trails in lists * adds remote lists * adds list activites * fixes lists * adds notifications * adds iri redirect * fixes comments and summitlogs * fixes follows and profiles * adds asynchronous send * fixes list search * adds encryption key * bug fixes * fixes activity signing * removes custon activity object types * adds html editor * fixes html editor * fixes display issues on mastodon * adds federated sharing * finishes announcements * fixes small summit log issues * adds trail likes * finalizes likes * fixes images for komoot * add disable federation option * adds private profiles * updates docs * adds federated comments * adds trail and comments actvitiypub routes * adds mentions to editor * adds mentions to trails, comments, summit logs * updates theme * updates theme * update docs * update docs * updates docs * fixes various frontend problems * fixes activitypub follows api * updates docs * updates docs --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
416
web/src/routes/profile/[handle]/stats/+page.svelte
Normal file
416
web/src/routes/profile/[handle]/stats/+page.svelte
Normal file
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import Calendar from "$lib/components/base/calendar.svelte";
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import MultiSelect from "$lib/components/base/multi_select.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import SummitLogTable from "$lib/components/summit_log/summit_log_table.svelte";
|
||||
import type { SummitLog } from "$lib/models/summit_log.js";
|
||||
import { categories } from "$lib/stores/category_store.js";
|
||||
import { profile_stats_index } from "$lib/stores/profile_store.js";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatSpeed,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import Bar from "$lib/vendor/svelte-chartjs/bar.svelte";
|
||||
import Pie from "$lib/vendor/svelte-chartjs/pie.svelte";
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
);
|
||||
|
||||
let summitLogs: SummitLog[] = $state(data.logs);
|
||||
|
||||
const filter = $state(data.filter);
|
||||
|
||||
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
|
||||
value: c.id,
|
||||
text: c.name,
|
||||
}));
|
||||
|
||||
const barChartSelectItems: SelectItem[] = [
|
||||
{
|
||||
text: $_("distance"),
|
||||
value: "distance",
|
||||
},
|
||||
{
|
||||
text: $_("duration"),
|
||||
value: "duration",
|
||||
},
|
||||
{
|
||||
text: $_("elevation-gain"),
|
||||
value: "elevation_gain",
|
||||
},
|
||||
{
|
||||
text: $_("elevation-loss"),
|
||||
value: "elevation_loss",
|
||||
},
|
||||
];
|
||||
|
||||
let barChartSelectedValue = $state(barChartSelectItems[0].value);
|
||||
|
||||
const categoryColors = [
|
||||
"#fb8500",
|
||||
"#ffb703",
|
||||
"#06d6a0",
|
||||
"#219ebc",
|
||||
"#8ecae6",
|
||||
"#ffafcc",
|
||||
];
|
||||
|
||||
const conversionFactors = {
|
||||
distance: 0.621371,
|
||||
elevation_gain: 3.28084,
|
||||
elevation_loss: 3.28084,
|
||||
};
|
||||
|
||||
let logCategories = $derived(
|
||||
summitLogs.reduce(
|
||||
(acc, log) => {
|
||||
const cat =
|
||||
log.expand?.trail?.expand?.category?.name ?? "unknown";
|
||||
acc[$_(cat)] = (acc[$_(cat)] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
);
|
||||
|
||||
let categoryLabels = $derived(Object.keys(logCategories).sort());
|
||||
let categoryValues = $derived(Object.values(logCategories));
|
||||
let categoryColorMap = $derived(
|
||||
Object.fromEntries(
|
||||
categoryLabels.map((label, index) => [
|
||||
label,
|
||||
categoryColors[index % categoryColors.length],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
let categoryChartData = $derived({
|
||||
labels: categoryLabels,
|
||||
datasets: [
|
||||
{
|
||||
data: categoryValues,
|
||||
backgroundColor: categoryColors,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function barChartUnit() {
|
||||
const unit = page.data.settings?.unit ?? "metric";
|
||||
|
||||
switch (barChartSelectedValue) {
|
||||
case "duration":
|
||||
return "h";
|
||||
case "elevation_gain":
|
||||
case "elevation_loss":
|
||||
return unit == "metric" ? "m" : "ft";
|
||||
case "distance":
|
||||
return unit == "metric" ? "km" : "mi";
|
||||
}
|
||||
}
|
||||
|
||||
let barChartDataByDate = $derived(
|
||||
summitLogs.reduce(
|
||||
(acc, log) => {
|
||||
const date = new Date(log.date).toLocaleDateString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
acc[date] =
|
||||
(acc[date] || 0) +
|
||||
((log as any)[barChartSelectedValue] ?? 0);
|
||||
if (barChartSelectedValue === "distance") {
|
||||
acc[date] = acc[date] / 1000;
|
||||
} else if (barChartSelectedValue === "duration") {
|
||||
acc[date] = acc[date] / 60 / 60;
|
||||
}
|
||||
if (page.data.settings?.unit !== "metric") {
|
||||
acc[date] =
|
||||
acc[date] *
|
||||
(conversionFactors as any)[barChartSelectedValue];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
);
|
||||
|
||||
let barChartLabels = $derived(Object.keys(barChartDataByDate)); // Dates as labels
|
||||
let barChartValues = $derived(Object.values(barChartDataByDate));
|
||||
|
||||
let barChartData = $derived({
|
||||
labels: barChartLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: barChartSelectItems.find(
|
||||
(i) => i.value === barChartSelectedValue,
|
||||
)?.text,
|
||||
data: barChartValues,
|
||||
backgroundColor: "#3388ff",
|
||||
borderRadius: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let totalDistance = $derived(
|
||||
summitLogs.reduce((sum, log) => sum + (log.distance ?? 0), 0),
|
||||
);
|
||||
|
||||
let totalDuration = $derived(
|
||||
summitLogs.reduce((sum, log) => sum + (log.duration ?? 0), 0),
|
||||
);
|
||||
|
||||
let totalElevationGain = $derived(
|
||||
summitLogs.reduce((sum, log) => sum + (log.elevation_gain ?? 0), 0),
|
||||
);
|
||||
|
||||
let totalElevationLoss = $derived(
|
||||
summitLogs.reduce((sum, log) => sum + (log.elevation_loss ?? 0), 0),
|
||||
);
|
||||
|
||||
let averageSpeed = $derived(
|
||||
totalDuration > 0
|
||||
? summitLogs.reduce(
|
||||
(sum, log) =>
|
||||
sum + (log.distance && log.duration ? log.distance : 0),
|
||||
0,
|
||||
) / totalDuration
|
||||
: undefined,
|
||||
);
|
||||
|
||||
function updateFilterCategory(categories: SelectItem[]) {
|
||||
filter.category = categories.map((c) => c.value);
|
||||
loadSummitLogs();
|
||||
}
|
||||
|
||||
function handleDateClick(date: Date) {
|
||||
const datePlusN = date;
|
||||
datePlusN.setDate(date.getDate() + 1);
|
||||
filter.startDate = datePlusN.toISOString().slice(0, 10);
|
||||
datePlusN.setDate(date.getDate() + 1);
|
||||
filter.endDate = datePlusN.toISOString().slice(0, 10);
|
||||
loadSummitLogs();
|
||||
}
|
||||
|
||||
async function loadSummitLogs() {
|
||||
try {
|
||||
const logs = await profile_stats_index(page.params.handle, filter);
|
||||
summitLogs = logs;
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
icon: "close",
|
||||
text: "Error loading stats.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("profile")} | wanderer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,_1fr)] gap-y-4 max-w-6xl mx-auto"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap lg:flex-nowrap col-start-1 lg:col-start-2 gap-x-4 justify-end"
|
||||
>
|
||||
<MultiSelect
|
||||
onchange={updateFilterCategory}
|
||||
label={$_("categories")}
|
||||
items={categorySelectItems}
|
||||
placeholder={`${$_("filter-categories")}...`}
|
||||
></MultiSelect>
|
||||
<Datepicker
|
||||
onchange={loadSummitLogs}
|
||||
bind:value={filter.startDate}
|
||||
label={$_("after")}
|
||||
></Datepicker>
|
||||
<Datepicker
|
||||
onchange={loadSummitLogs}
|
||||
bind:value={filter.endDate}
|
||||
label={$_("before")}
|
||||
></Datepicker>
|
||||
</div>
|
||||
<div class="space-y-4 grow-0 lg:mr-4">
|
||||
<div class="border border-input-border rounded-xl p-6">
|
||||
<Calendar
|
||||
onclick={handleDateClick}
|
||||
logs={summitLogs}
|
||||
colorMap={categoryColorMap}
|
||||
></Calendar>
|
||||
</div>
|
||||
<div class="border border-input-border rounded-xl p-6 space-y-4">
|
||||
<span class="text-gray-500 font-semibold text-lg"
|
||||
><i class="fa fa-person-hiking mr-3"></i>{$_(
|
||||
"categories",
|
||||
)}</span
|
||||
>
|
||||
<Pie
|
||||
data={categoryChartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-hashtag mr-3"></i>{$_("activity", {
|
||||
values: { n: 2 },
|
||||
})}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">{summitLogs.length}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-left-right mr-3"></i>{$_("distance")}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">{formatDistance(totalDistance)}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-clock mr-3"></i>{$_("duration")}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">
|
||||
{formatTimeHHMM(totalDuration / 60)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
||||
"average-speed",
|
||||
)}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">{formatSpeed(averageSpeed)}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-arrow-trend-up mr-3"></i>{$_(
|
||||
"elevation-gain",
|
||||
)}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">
|
||||
{formatElevation(totalElevationGain)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
||||
"elevation-loss",
|
||||
)}</span
|
||||
>
|
||||
<p class="text-3xl font-bold">
|
||||
{formatElevation(totalElevationLoss)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-full lg:col-span-2 space-y-2 border border-input-border rounded-xl p-6"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 font-semibold text-lg"
|
||||
><i class="fa fa-calendar mr-3"></i>{$_("activity", {
|
||||
values: { n: 1 },
|
||||
})}</span
|
||||
>
|
||||
<Select
|
||||
bind:value={barChartSelectedValue}
|
||||
items={barChartSelectItems}
|
||||
></Select>
|
||||
</div>
|
||||
|
||||
<Bar
|
||||
data={barChartData}
|
||||
options={{
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (item) =>
|
||||
`${item.dataset.label}: ${item.formattedValue} ${barChartUnit()}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function (value, index, ticks) {
|
||||
return value + " " + barChartUnit();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
></Bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-1 lg:col-span-2 border border-input-border rounded-xl p-6 space-y-6"
|
||||
>
|
||||
<span class="text-gray-500 font-semibold text-lg"
|
||||
><i class="fa fa-table mr-3"></i>{$_("all-activities")}</span
|
||||
>
|
||||
<div class=" overflow-x-auto">
|
||||
<SummitLogTable
|
||||
summitLogs={data.logs}
|
||||
handle={page.params.handle}
|
||||
showCategory
|
||||
showTrail
|
||||
showRoute
|
||||
></SummitLogTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
33
web/src/routes/profile/[handle]/stats/+page.ts
Normal file
33
web/src/routes/profile/[handle]/stats/+page.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { SummitLogFilter } from "$lib/models/summit_log";
|
||||
import { categories_index } from "$lib/stores/category_store";
|
||||
import { profile_stats_index } from "$lib/stores/profile_store";
|
||||
import { error, type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch, parent }) => {
|
||||
|
||||
if (!params.handle) {
|
||||
error(404, "Not found")
|
||||
}
|
||||
|
||||
const date = new Date()
|
||||
date.setUTCHours(6)
|
||||
const y = date.getFullYear()
|
||||
const m = date.getMonth();
|
||||
const firstDay = new Date(y, m, 2);
|
||||
const lastDay = new Date(y, m + 1, 1);
|
||||
|
||||
const categories = await categories_index(fetch)
|
||||
|
||||
const filter: SummitLogFilter = {
|
||||
startDate: firstDay.toISOString().slice(0, 10),
|
||||
endDate: lastDay.toISOString().slice(0, 10),
|
||||
category: []
|
||||
}
|
||||
try {
|
||||
const logs = await profile_stats_index(params.handle, filter, fetch);
|
||||
return { filter, logs }
|
||||
|
||||
} catch (e) {
|
||||
return { logs: [], filter }
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user